From 28ebc9fe4e63ed99b886492f285e38a21fcf4dff Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Mon, 9 Feb 2026 11:30:55 +0100 Subject: [PATCH 1/6] cbscore/builder: ignore cosign install if already installed * what: if the return code of the rpm process is 2, check if the failure reason is that the package is already installed. * why: when reusing a container, the package might already be present. this occurs when a build runner job must be debugged. Signed-off-by: Uwe Schwaeke --- cbscore/src/cbscore/builder/prepare.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cbscore/src/cbscore/builder/prepare.py b/cbscore/src/cbscore/builder/prepare.py index 7c6c1119..491dcbc1 100644 --- a/cbscore/src/cbscore/builder/prepare.py +++ b/cbscore/src/cbscore/builder/prepare.py @@ -115,7 +115,7 @@ async def _cb(s: str) -> None: ) logger.debug(stdout) if rc == 2 and re.match(".*already installed.*", stderr): - msg = f'skip install cosign. allready installed' + msg = "skip install cosign. already installed" logger.debug(msg) elif rc != 0: msg = f"error installing cosign package: {stderr}" From 16be6a413cb5edac2c65fbab8c086fb6a7cb244b Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Mon, 9 Feb 2026 11:31:46 +0100 Subject: [PATCH 2/6] cbscore: let skopeo handle local registries * what: add option --tls-verify to subcommands build and runner build. pass the tls-verify flag to skopeo when querying the registry. check if the return value from skopeo inspect equals "not found" (exit code 2). * why: if the image is pushed to a local container registry with a self-signed certificate, skopeo must not verify the certificate to avoid errors. current versions of skopeo (1.20.0) return exit code 2 if an image is not found. Signed-off-by: Uwe Schwaeke --- cbscore/src/cbscore/images/skopeo.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cbscore/src/cbscore/images/skopeo.py b/cbscore/src/cbscore/images/skopeo.py index a00064cd..e8be8023 100644 --- a/cbscore/src/cbscore/images/skopeo.py +++ b/cbscore/src/cbscore/images/skopeo.py @@ -147,6 +147,8 @@ def skopeo_inspect(img: str, secrets: SecretsMgr, *, tls_verify: bool = True) -> if retcode != 0: msg = f"error inspecting image '{img}': {err}" + # Exit code 2 is specifically used by skopeo 1.20.0+ to signal "not found". + # if retcode == 2 or re.match(r".*not\s+found.*", err): logger.debug(msg) raise ImageNotFoundError(img) from None From ccf4f23c353fbb7ea2f20380fbed5e6334bd20cc Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Wed, 18 Feb 2026 12:02:10 +0100 Subject: [PATCH 3/6] cbscore: let buildah handle local registries * what: pass the tls-verify flag to buildah when pushing to the registry. * why: if the image is pushed to a local container registry with a self-signed certificate, buildah must skip certificate verification to avoid errors Signed-off-by: Uwe Schwaeke --- cbscore/src/cbscore/builder/builder.py | 4 +++- cbscore/src/cbscore/containers/build.py | 9 +++++++-- cbscore/src/cbscore/utils/buildah.py | 20 +++++++++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cbscore/src/cbscore/builder/builder.py b/cbscore/src/cbscore/builder/builder.py index 984f2b57..d69b308f 100644 --- a/cbscore/src/cbscore/builder/builder.py +++ b/cbscore/src/cbscore/builder/builder.py @@ -166,7 +166,9 @@ async def run(self) -> None: return try: - ctr_builder = ContainerBuilder(self.desc, release_desc, self.components) + ctr_builder = ContainerBuilder( + self.desc, release_desc, self.components, tls_verify=self.tls_verify + ) await ctr_builder.build() await ctr_builder.finish( self.secrets, diff --git a/cbscore/src/cbscore/containers/build.py b/cbscore/src/cbscore/containers/build.py index 6e2d634e..b1d923c8 100644 --- a/cbscore/src/cbscore/containers/build.py +++ b/cbscore/src/cbscore/containers/build.py @@ -29,7 +29,7 @@ class ContainerBuilder: version_desc: VersionDescriptor release_desc: ReleaseDesc components: dict[str, CoreComponentLoc] - + tls_verify: bool container: BuildahContainer | None def __init__( @@ -37,10 +37,13 @@ def __init__( version_desc: VersionDescriptor, release_desc: ReleaseDesc, components: dict[str, CoreComponentLoc], + *, + tls_verify: bool = True, ) -> None: self.version_desc = version_desc self.release_desc = release_desc self.components = components + self.tls_verify = tls_verify self.container = None async def build(self) -> None: @@ -238,4 +241,6 @@ async def finish( ) -> None: logger.info(f"finish container for '{self.version_desc.version}'") assert self.container - await self.container.finish(secrets, sign_with_transit=sign_with_transit) + await self.container.finish( + secrets, sign_with_transit=sign_with_transit, tls_verify=self.tls_verify + ) diff --git a/cbscore/src/cbscore/utils/buildah.py b/cbscore/src/cbscore/utils/buildah.py index efd6d084..6c14ffe5 100644 --- a/cbscore/src/cbscore/utils/buildah.py +++ b/cbscore/src/cbscore/utils/buildah.py @@ -185,6 +185,7 @@ async def finish( secrets: SecretsMgr, *, sign_with_transit: str | None = None, + tls_verify: bool = True, ) -> None: # output to logger async def _out(s: str) -> None: @@ -252,7 +253,12 @@ async def _out(s: str) -> None: logger.info(f"pushing image '{uri}' to '{self.version_desc.image.registry}'") digest_file_fd, digest_file = tempfile.mkstemp(text=True) - push_cmd: CmdArgs = ["push", "--digestfile", digest_file] + push_cmd: CmdArgs = [ + "push", + f"--tls-verify={tls_verify}", + "--digestfile", + digest_file, + ] push_cmd.extend( ["--creds", Password(f"{username}:{password}")] if username and password @@ -297,10 +303,18 @@ async def _out(s: str) -> None: raise BuildahError(msg) -async def buildah_new_container(desc: VersionDescriptor) -> BuildahContainer: +async def buildah_new_container( + desc: VersionDescriptor, volumes: dict[str, str] | None = None +) -> BuildahContainer: + volumes = volumes or {} + volume_opts: CmdArgs = [] + for local, inside in volumes.items(): + volume_opts.extend(["-v", f"{local}:{inside}:Z"]) + create_args: CmdArgs = ["from", desc.distro] + params = volume_opts + create_args try: - rc, stdout, stderr = await _buildah_run(create_args) + rc, stdout, stderr = await _buildah_run(params) except BuildahError as e: msg = f"error creating new container: {e}" logger.exception(msg) From 7989bd8b109a5f8e4bf9990752b3bf44dc33997c Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Wed, 18 Feb 2026 15:26:53 +0100 Subject: [PATCH 4/6] cbscore: use local storage for container image build * what pass base_url (schema + authority) and gpg signing options to the build script. replace base_url in container.yaml with the local url or the s3 url. mount the folders containing the rpms using buildah when building locally. * why to build an image for testing purposes without deploying rpms to the production environment, it is necessary to mock the remote storage. Signed-off-by: Uwe Schwaeke --- cbscore/src/cbscore/__main__.py | 12 ++- .../src/cbscore/_tools/cbscore-entrypoint.sh | 6 +- cbscore/src/cbscore/builder/builder.py | 93 +++++++++++++++++-- cbscore/src/cbscore/builder/rpmbuild.py | 11 ++- cbscore/src/cbscore/builder/upload.py | 34 +------ cbscore/src/cbscore/cmds/__init__.py | 1 + cbscore/src/cbscore/cmds/builds.py | 8 ++ cbscore/src/cbscore/containers/build.py | 30 +++++- cbscore/src/cbscore/releases/desc.py | 10 +- cbscore/src/cbscore/runner.py | 3 + cbscore/src/cbscore/utils/secrets/utils.py | 5 + cbscore/src/cbscore/utils/uris.py | 1 + .../ceph/containers/v20.2/container.yaml | 3 +- components/ceph/scripts/build_rpms.sh | 4 +- 14 files changed, 171 insertions(+), 50 deletions(-) diff --git a/cbscore/src/cbscore/__main__.py b/cbscore/src/cbscore/__main__.py index 449de0d6..715bd1b0 100755 --- a/cbscore/src/cbscore/__main__.py +++ b/cbscore/src/cbscore/__main__.py @@ -54,12 +54,22 @@ required=True, default="cbs-build.config.yaml", ) +@click.option( + "-l", + "--local", + "local", + is_flag=True, + default=False, + required=False, + help="Run without access to s3", +) @pass_ctx -def cmd_main(ctx: Ctx, debug: bool, config_path: Path) -> None: +def cmd_main(ctx: Ctx, debug: bool, config_path: Path, local: bool) -> None: if debug: set_log_level(logging.DEBUG) ctx.config_path = config_path + ctx.local = local cmd_main.add_command(builds.cmd_build) diff --git a/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh b/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh index b9df3fa4..0a49ef25 100755 --- a/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh +++ b/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh @@ -53,7 +53,11 @@ uv --directory "${CBSCORE_PATH}" \ dbg= [[ -n ${CBS_DEBUG} ]] && [[ ${CBS_DEBUG} == "1" ]] && dbg="--debug" + +local_run= +[[ -n ${CBS_LOCAL} ]] && [[ ${CBS_LOCAL} == "1" ]] && local_run="--local" + # shellcheck disable=2048,SC2086 -cbsbuild --config "${RUNNER_PATH}/cbs-build.config.yaml" ${dbg} \ +cbsbuild --config "${RUNNER_PATH}/cbs-build.config.yaml" ${dbg} ${local_run} \ runner build \ $* || exit 1 diff --git a/cbscore/src/cbscore/builder/builder.py b/cbscore/src/cbscore/builder/builder.py index d69b308f..a7ca041d 100644 --- a/cbscore/src/cbscore/builder/builder.py +++ b/cbscore/src/cbscore/builder/builder.py @@ -11,6 +11,8 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. +import asyncio +import shutil from pathlib import Path from cbscore.builder import BuilderError @@ -30,8 +32,10 @@ from cbscore.images.skopeo import skopeo_image_exists from cbscore.releases import ReleaseError from cbscore.releases.desc import ( + S3_BASE_URL, ArchType, BuildType, + LocalReleaseRPMArtifacts, ReleaseBuildEntry, ReleaseComponent, ReleaseComponentVersion, @@ -45,6 +49,7 @@ release_upload_components, ) from cbscore.releases.utils import get_component_release_rpm +from cbscore.utils import CommandError, async_run_cmd from cbscore.utils.containers import get_container_canonical_uri from cbscore.utils.secrets import SecretsMgrError from cbscore.utils.secrets.mgr import SecretsMgr @@ -65,6 +70,9 @@ class Builder: skip_build: bool force: bool tls_verify: bool + local: bool + base_url: str + dev: bool def __init__( self, @@ -73,7 +81,9 @@ def __init__( *, skip_build: bool = False, force: bool = False, - tls_verify: bool = True + tls_verify: bool = True, + local: bool = False, + dev: bool = False, ) -> None: self.desc = desc self.config = config @@ -85,6 +95,9 @@ def __init__( self.skip_build = skip_build self.force = force self.tls_verify = tls_verify + self.local = local + self.base_url = "file://" if self.local else S3_BASE_URL + self.dev = dev try: vault_config = self.config.get_vault_config() @@ -112,7 +125,9 @@ async def run(self) -> None: raise BuilderError(msg=msg) from e container_img_uri = get_container_canonical_uri(self.desc) - if skopeo_image_exists(container_img_uri, self.secrets, tls_verify=self.tls_verify): + if skopeo_image_exists( + container_img_uri, self.secrets, tls_verify=self.tls_verify + ): logger.info(f"image '{container_img_uri}' already exists -- do not build!") return else: @@ -206,6 +221,7 @@ async def _build_release(self) -> ReleaseDesc | None: self.components, self.desc.components, self.desc.version, + dev=self.dev, ) as components: return await self._do_build_release(components) except BuilderError as e: @@ -229,6 +245,10 @@ async def _do_build_release( Will return `None` if `self.upload` is `False`. """ + if not self.local and (not self.storage_config or not self.storage_config.s3): + logger.warning("not uploading per config, stop release build") + return None + # Check if any of the components have been previously built, and, if so, # reuse them instead of building them. # @@ -281,10 +301,6 @@ async def _do_build_release( logger.error(msg) raise BuilderError(msg) from e - if not self.storage_config or not self.storage_config.s3: - logger.warning("not uploading per config, stop release build") - return None - comp_versions = existing.copy() comp_versions.update(built) @@ -304,7 +320,13 @@ async def _do_build_release( ) release: ReleaseDesc | None = None - if self.storage_config and self.storage_config.s3: + if self.local: + release = ReleaseDesc( + version=self.desc.version, + builds={release_build.arch: release_build}, + base_url=self.base_url, + ) + elif self.storage_config and self.storage_config.s3: try: release = await release_desc_upload( self.secrets, @@ -344,6 +366,23 @@ async def _build( logger.error(msg) raise BuilderError(msg) from e + await self._create_repos(comp_builds) + + if self.local: + return { + comp_name: ReleaseComponentVersion( + name=components[comp_name].name, + version=components[comp_name].long_version, + sha1=components[comp_name].sha1, + arch=ArchType.x86_64, + build_type=BuildType.rpm, + os_version=f"el{self.desc.el_version}", + repo_url=components[comp_name].repo_url, + artifacts=LocalReleaseRPMArtifacts(loc=comp_build.rpms_path), + ) + for comp_name, comp_build in comp_builds.items() + } + if not self.storage_config or not self.storage_config.s3: return {} @@ -372,7 +411,7 @@ async def _build_rpms( rpms_path = self.scratch_path.joinpath("rpms") rpms_path.mkdir(exist_ok=True) - + is_sign_rpms = (self.signing_config and self.signing_config.gpg) is not None try: comp_builds = await build_rpms( rpms_path, @@ -381,13 +420,15 @@ async def _build_rpms( components, ccache_path=self.ccache_path, skip_build=self.skip_build, + base_url=self.base_url, + is_sign_rpms=is_sign_rpms, ) except (BuilderError, Exception) as e: msg = f"error building components ({components.keys()}): {e}" logger.error(msg) raise BuilderError(msg) from e - if not self.signing_config or not self.signing_config.gpg: + if not is_sign_rpms: logger.warning("no signing method provided, skip signing RPMs") else: logger.info(f"signing RPMs with gpg key '{self.signing_config.gpg}'") @@ -404,6 +445,40 @@ async def _build_rpms( return comp_builds + async def _create_repos(self, comp_builds: dict[str, ComponentBuild]) -> None: + logger.info(f"creating rpm repos for {comp_builds.keys()}") + async with asyncio.TaskGroup() as tg: + for comp_name, comp_build in comp_builds.items(): + logger.info(f"creating rpm repo for {comp_name}") + _ = tg.create_task(self._create_repo(comp_build)) + + async def _create_repo(self, comp_build: ComponentBuild) -> None: + async def _do_create_repo(p: Path) -> None: + repodata_path = p.joinpath("repodata") + if repodata_path.exists(): + shutil.rmtree(repodata_path) + + try: + _ = await async_run_cmd(["createrepo", p.resolve().as_posix()]) + except CommandError as e: + msg = f"error creating repodata at '{repodata_path}': {e}" + logger.exception(msg) + raise BuilderError(msg) from e + except Exception as e: + msg = f"unknown error creating repodata at '{repodata_path}': {e}" + logger.exception(msg) + raise BuilderError(msg) from e + + if not repodata_path.exists() or not repodata_path.is_dir(): + msg = f"unexpected missing repodata dir at '{repodata_path}'" + logger.error(msg) + raise BuilderError(msg) + + rpm_folders = {f.parent for f in comp_build.rpms_path.rglob("*.rpm")} + + for p in rpm_folders: + await _do_create_repo(p) + async def _upload( self, comp_infos: dict[str, BuildComponentInfo], diff --git a/cbscore/src/cbscore/builder/rpmbuild.py b/cbscore/src/cbscore/builder/rpmbuild.py index 84da7d94..6f53326f 100644 --- a/cbscore/src/cbscore/builder/rpmbuild.py +++ b/cbscore/src/cbscore/builder/rpmbuild.py @@ -60,6 +60,8 @@ async def _build_component( *, ccache_path: Path | None = None, skip_build: bool = False, + base_url: str = '""', + is_sign_rpms: bool = True, ) -> tuple[int, Path]: """ Build a given component. @@ -88,8 +90,9 @@ async def _outcb(s: str) -> None: comp_rpms_path.resolve().as_posix(), ] - if version: - cmd.append(version) + cmd.append(version if version else '""') + cmd.append(base_url) + cmd.append("1" if is_sign_rpms else "0") extra_env: dict[str, str] | None = None if ccache_path is not None: @@ -177,6 +180,8 @@ async def build_rpms( *, ccache_path: Path | None = None, skip_build: bool = False, + base_url: str | None = None, + is_sign_rpms: bool = True, ) -> dict[str, ComponentBuild]: """ Build RPMs for the various components provided in `components`. @@ -245,6 +250,8 @@ def __init__(self, build_script: Path, version: str) -> None: to_build[name].version, ccache_path=ccache_path, skip_build=skip_build, + base_url=base_url, + is_sign_rpms=is_sign_rpms, ) ) for name in to_build diff --git a/cbscore/src/cbscore/builder/upload.py b/cbscore/src/cbscore/builder/upload.py index 94eda8e4..bffe933d 100644 --- a/cbscore/src/cbscore/builder/upload.py +++ b/cbscore/src/cbscore/builder/upload.py @@ -12,13 +12,11 @@ # GNU General Public License for more details. import asyncio -import shutil from pathlib import Path from cbscore.builder import BuilderError from cbscore.builder import logger as parent_logger from cbscore.builder.rpmbuild import ComponentBuild -from cbscore.utils import CommandError, async_run_cmd from cbscore.utils.s3 import S3Error, S3FileLocator, s3_upload_files from cbscore.utils.secrets.mgr import SecretsMgr @@ -57,34 +55,7 @@ def _get_rpms( async def _get_repo( target_path: Path, s3_base_dst: str, relative_to: Path ) -> list[S3FileLocator]: - # create a repository at 'p', and return the corresponding - # 'repodata' directory path. - async def _create_repo(p: Path) -> Path: - repodata_path = p.joinpath("repodata") - if repodata_path.exists(): - shutil.rmtree(repodata_path) - - try: - _ = await async_run_cmd(["createrepo", p.resolve().as_posix()]) - except CommandError as e: - msg = f"error creating repodata at '{repodata_path}': {e}" - logger.exception(msg) - raise BuilderError(msg) from e - except Exception as e: - msg = f"unknown error creating repodata at '{repodata_path}': {e}" - logger.exception(msg) - raise BuilderError(msg) from e - - if not repodata_path.exists() or not repodata_path.is_dir(): - msg = f"unexpected missing repodata dir at '{repodata_path}'" - logger.error(msg) - raise BuilderError(msg) - - return repodata_path - - # get all the 'repodata' directories for this path. A repository will - # be created under 'p' if at least one RPM exists. Will still descend - # into all child directories, doing the same. + # get all the 'repodata' directories for this path. async def _get_repo_r(p: Path) -> list[Path]: repo_paths: list[Path] = [] @@ -95,7 +66,8 @@ async def _get_repo_r(p: Path) -> list[Path]: continue if entry.suffix == ".rpm" and not has_repo: - repo_paths.append(await _create_repo(entry.parent)) + # repositories are created after the build of each component. + repo_paths.append(entry.parent.joinpath("repodata")) continue return repo_paths diff --git a/cbscore/src/cbscore/cmds/__init__.py b/cbscore/src/cbscore/cmds/__init__.py index d39a0c0e..04ff5a3f 100644 --- a/cbscore/src/cbscore/cmds/__init__.py +++ b/cbscore/src/cbscore/cmds/__init__.py @@ -28,6 +28,7 @@ class Ctx: config_path: Path | None = None + local: bool = False pass_ctx = click.make_pass_decorator(Ctx, ensure=True) diff --git a/cbscore/src/cbscore/cmds/builds.py b/cbscore/src/cbscore/cmds/builds.py index 44ae6fb7..a9c90820 100644 --- a/cbscore/src/cbscore/cmds/builds.py +++ b/cbscore/src/cbscore/cmds/builds.py @@ -147,6 +147,9 @@ def cmd_build( try: config = Config.load(ctx.config_path) + # remove s3 config if build locally. + if ctx.local and config.storage: + config.storage.s3 = None except Exception as e: click.echo(f"error loading config from '{ctx.config_path}': {e}", err=True) sys.exit(errno.ENOTRECOVERABLE) @@ -199,6 +202,7 @@ def cmd_build( skip_build=skip_build, force=force, tls_verify=tls_verify, + local=ctx.local, ) ) @@ -258,7 +262,9 @@ def cmd_runner_grp() -> None: default=True, ) @with_config +@pass_ctx def cmd_runner_build( + ctx: Ctx, config: Config, desc_path: Path, skip_build: bool, @@ -299,6 +305,7 @@ def cmd_runner_build( skip build: {skip_build} force: {force} tls-verify: {tls_verify} + push to s3: {not ctx.local} """) if not desc_path.exists(): @@ -318,6 +325,7 @@ def cmd_runner_build( skip_build=skip_build, force=force, tls_verify=tls_verify, + local=ctx.local, ) except BuilderError as e: logger.error(f"unable to initialize builder: {e}") diff --git a/cbscore/src/cbscore/containers/build.py b/cbscore/src/cbscore/containers/build.py index b1d923c8..4e12ed7a 100644 --- a/cbscore/src/cbscore/containers/build.py +++ b/cbscore/src/cbscore/containers/build.py @@ -17,7 +17,12 @@ from cbscore.containers import ContainerError from cbscore.containers.component import ComponentContainer from cbscore.core.component import CoreComponentLoc -from cbscore.releases.desc import ArchType, ReleaseDesc +from cbscore.releases.desc import ( + S3_BASE_URL, + ArchType, + LocalReleaseRPMArtifacts, + ReleaseDesc, +) from cbscore.utils.buildah import BuildahContainer, BuildahError, buildah_new_container from cbscore.utils.secrets.mgr import SecretsMgr from cbscore.versions.desc import VersionDescriptor @@ -54,7 +59,27 @@ async def build(self) -> None: logger.exception(msg) raise ContainerError(msg) from e - self.container = await buildah_new_container(self.version_desc) + volumes_to_mount: dict[str, str] = {} + for _, build_entry in self.release_desc.builds.items(): + build_type = build_entry.build_type + os_version = build_entry.os_version + for comp, comp_ver in build_entry.components.items(): + release_artifacts = comp_ver.artifacts + if isinstance(release_artifacts, LocalReleaseRPMArtifacts): + inside_base_folder = ( + f"/components/{comp}/{build_type}-{comp_ver.version}/" + + f"{os_version}.clyso" + ) + volumes_to_mount[f"{release_artifacts.loc}/RPMS"] = ( + inside_base_folder + ) + volumes_to_mount[f"{release_artifacts.loc}/SRPMS"] = ( + f"{inside_base_folder}/SRPMS" + ) + + self.container = await buildah_new_container( + self.version_desc, volumes_to_mount + ) try: await self.apply_pre(components) @@ -123,6 +148,7 @@ async def get_components( "git_repo_url": release_comp.repo_url, "component_name": release_comp.name, "distro": self.version_desc.distro, + "base_url": self.release_desc.base_url or S3_BASE_URL, } try: diff --git a/cbscore/src/cbscore/releases/desc.py b/cbscore/src/cbscore/releases/desc.py index c271bdf0..d5d13bf3 100644 --- a/cbscore/src/cbscore/releases/desc.py +++ b/cbscore/src/cbscore/releases/desc.py @@ -24,6 +24,9 @@ logger = parent_logger.getChild("desc") +S3_BASE_URL = "https://ces-packages.s3.clyso.com" + + class ArchType(enum.StrEnum): x86_64 = "x86_64" @@ -55,10 +58,14 @@ class ReleaseRPMArtifacts(pydantic.BaseModel): release_rpm_loc: str +class LocalReleaseRPMArtifacts(pydantic.BaseModel): + loc: Path + + # allow extending this type, possibly including discriminators, # should we want to add other build types in the future. # -ReleaseArtifacts = ReleaseRPMArtifacts +ReleaseArtifacts = ReleaseRPMArtifacts | LocalReleaseRPMArtifacts class ReleaseComponentVersion(ReleaseComponentHeader, BuildInfo): @@ -126,6 +133,7 @@ class ReleaseDesc(pydantic.BaseModel): version: str builds: dict[ArchType, ReleaseBuildEntry] + base_url: str | None = S3_BASE_URL @classmethod def load(cls, path: Path) -> ReleaseDesc: diff --git a/cbscore/src/cbscore/runner.py b/cbscore/src/cbscore/runner.py index c3029f85..89f233b3 100644 --- a/cbscore/src/cbscore/runner.py +++ b/cbscore/src/cbscore/runner.py @@ -119,6 +119,7 @@ async def runner( skip_build: bool = False, force: bool = False, tls_verify: bool = True, + local: bool = False, ) -> None: our_actual_loc = Path(__file__).parent @@ -176,6 +177,7 @@ async def runner( skip build: {skip_build} force: {force} tls-verify: {tls_verify} + push to s3: {not local} """) if not entrypoint_path.exists() or not entrypoint_path.is_file(): @@ -288,6 +290,7 @@ async def runner( "CBS_DEBUG": "1" if logger.getEffectiveLevel() == logging.DEBUG else "0", + "CBS_LOCAL": "1" if local else "0", }, args=podman_args, volumes=podman_volumes, diff --git a/cbscore/src/cbscore/utils/secrets/utils.py b/cbscore/src/cbscore/utils/secrets/utils.py index 44488571..090b8b0f 100644 --- a/cbscore/src/cbscore/utils/secrets/utils.py +++ b/cbscore/src/cbscore/utils/secrets/utils.py @@ -83,6 +83,11 @@ def find_best_secret_candidate(secrets: list[str], uri: str) -> str | None: "foo.bar.tld/foo/bar", "foo.bar.tld/foo", ), + ( + ["127.0.0.1"], + "127.0.0.1:5000/foo", + "127.0.0.1", + ), ] for case in _test_cases: diff --git a/cbscore/src/cbscore/utils/uris.py b/cbscore/src/cbscore/utils/uris.py index da667848..acf0d0dc 100644 --- a/cbscore/src/cbscore/utils/uris.py +++ b/cbscore/src/cbscore/utils/uris.py @@ -37,6 +37,7 @@ def matches_uri(pattern: str, uri: str) -> tuple[bool, bool, str | None]: ^ (?:(?Pgit|https?|ssh)://)? (?P[\w\.\-]+) + (?::\d+)? (?P(?:/[\w\.\-]+)*)?/? $ """, diff --git a/components/ceph/containers/v20.2/container.yaml b/components/ceph/containers/v20.2/container.yaml index 860a7b65..138efd79 100644 --- a/components/ceph/containers/v20.2/container.yaml +++ b/components/ceph/containers/v20.2/container.yaml @@ -23,7 +23,8 @@ pre: - epel-release - jq - dnf-plugins-core - - https://ces-packages.s3.clyso.com/components/ceph/rpm-{version}/el{el}.clyso/noarch/ceph-release-2-1.el{el}.clyso.noarch.rpm + # - https://ces-packages.s3.clyso.com/components/ceph/rpm-{version}/el{el}.clyso/noarch/ceph-release-2-1.el{el}.clyso.noarch.rpm + - {base_url}/components/ceph/rpm-{version}/el{el}.clyso/noarch/ceph-release-2-1.el{el}.clyso.noarch.rpm repos: - name: ganesha diff --git a/components/ceph/scripts/build_rpms.sh b/components/ceph/scripts/build_rpms.sh index 39e0ab11..8ad35e7f 100755 --- a/components/ceph/scripts/build_rpms.sh +++ b/components/ceph/scripts/build_rpms.sh @@ -69,17 +69,17 @@ build_ceph_release_rpm() { local el_version="${2}" local topdir="${3}" local version="${4}" + local base_url=$"${5:-https://s3.clyso.com/ces-packages}" + local gpgcheck=$"${6:-1}" echo "Build Ceph RPM release package" summary="CES Ceph repository configuration" project_url=https://www.clyso.com/ epoch=1 # means a non-development release (0 would be development) - base_url="https://s3.clyso.com/ces-packages" target="el${el_version}.clyso" repo_base_url="${base_url}/components/ceph/rpm-${version}/${target}" # repo_base_url="http://download.ceph.com/rpm-${ceph_release}/${target}" - gpgcheck=1 gpgkey=https://s3.clyso.com/ces-packages/release.asc dist_version=".el${el_version}.clyso" From 83146c7bed55dcfc4024a269ad4171460997b6c7 Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Mon, 23 Feb 2026 16:24:54 +0100 Subject: [PATCH 5/6] cbscore: allow http on dev execution * what: add a --dev flag, which can also be set via an environment variable, to allow http git urls. * why: during development and testing, the bootstrapped gitea server does not have a trusted certificate, making http necessary. Signed-off-by: Uwe Schwaeke --- cbscore/src/cbscore/__main__.py | 9 ++++++++- cbscore/src/cbscore/_tools/cbscore-entrypoint.sh | 6 +++++- cbscore/src/cbscore/builder/prepare.py | 4 +++- cbscore/src/cbscore/builder/rpmbuild.py | 6 +++--- cbscore/src/cbscore/cmds/__init__.py | 1 + cbscore/src/cbscore/cmds/builds.py | 2 ++ cbscore/src/cbscore/runner.py | 3 +++ cbscore/src/cbscore/utils/secrets/git.py | 14 ++++++++++---- cbscore/src/cbscore/utils/secrets/mgr.py | 4 ++-- 9 files changed, 37 insertions(+), 12 deletions(-) diff --git a/cbscore/src/cbscore/__main__.py b/cbscore/src/cbscore/__main__.py index 715bd1b0..96059fa9 100755 --- a/cbscore/src/cbscore/__main__.py +++ b/cbscore/src/cbscore/__main__.py @@ -35,6 +35,12 @@ @click.group() +@click.option( + "--dev", + help="Run in development mode. Only used for testing.", + is_flag=True, + envvar="CBS_DEV", +) @click.option( "-d", "--debug", help="Enable debug output", is_flag=True, envvar="CBS_DEBUG" ) @@ -64,12 +70,13 @@ help="Run without access to s3", ) @pass_ctx -def cmd_main(ctx: Ctx, debug: bool, config_path: Path, local: bool) -> None: +def cmd_main(ctx: Ctx, dev: bool, debug: bool, config_path: Path, local: bool) -> None: if debug: set_log_level(logging.DEBUG) ctx.config_path = config_path ctx.local = local + ctx.dev = dev cmd_main.add_command(builds.cmd_build) diff --git a/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh b/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh index 0a49ef25..14d37f52 100755 --- a/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh +++ b/cbscore/src/cbscore/_tools/cbscore-entrypoint.sh @@ -57,7 +57,11 @@ dbg= local_run= [[ -n ${CBS_LOCAL} ]] && [[ ${CBS_LOCAL} == "1" ]] && local_run="--local" +dev= +[[ -n ${CBS_DEV} ]] && [[ ${CBS_DEV} == "1" ]] && dev="--dev" + + # shellcheck disable=2048,SC2086 -cbsbuild --config "${RUNNER_PATH}/cbs-build.config.yaml" ${dbg} ${local_run} \ +cbsbuild --config "${RUNNER_PATH}/cbs-build.config.yaml" ${dbg} ${local_run} ${dev} \ runner build \ $* || exit 1 diff --git a/cbscore/src/cbscore/builder/prepare.py b/cbscore/src/cbscore/builder/prepare.py index 491dcbc1..7af2800a 100644 --- a/cbscore/src/cbscore/builder/prepare.py +++ b/cbscore/src/cbscore/builder/prepare.py @@ -182,6 +182,8 @@ async def prepare_components( components_loc: dict[str, CoreComponentLoc], components: list[VersionComponent], version: str, + *, + dev: bool, ) -> AsyncGenerator[dict[str, BuildComponentInfo]]: """ Prepare all components by cloning them and applying required patches. @@ -217,7 +219,7 @@ async def _clone_repo(comp: VersionComponent) -> Path: ) start = dt.now(tz=datetime.UTC) try: - with secrets.git_url_for(comp.repo) as comp_url: + with secrets.git_url_for(comp.repo, dev=dev) as comp_url: cloned_path = await git.git_clone( comp_url, git_repos_path, diff --git a/cbscore/src/cbscore/builder/rpmbuild.py b/cbscore/src/cbscore/builder/rpmbuild.py index 6f53326f..e59bf661 100644 --- a/cbscore/src/cbscore/builder/rpmbuild.py +++ b/cbscore/src/cbscore/builder/rpmbuild.py @@ -60,7 +60,7 @@ async def _build_component( *, ccache_path: Path | None = None, skip_build: bool = False, - base_url: str = '""', + base_url: str = "", is_sign_rpms: bool = True, ) -> tuple[int, Path]: """ @@ -91,7 +91,7 @@ async def _outcb(s: str) -> None: ] cmd.append(version if version else '""') - cmd.append(base_url) + cmd.append(base_url if base_url else '""') cmd.append("1" if is_sign_rpms else "0") extra_env: dict[str, str] | None = None @@ -180,7 +180,7 @@ async def build_rpms( *, ccache_path: Path | None = None, skip_build: bool = False, - base_url: str | None = None, + base_url: str = "", is_sign_rpms: bool = True, ) -> dict[str, ComponentBuild]: """ diff --git a/cbscore/src/cbscore/cmds/__init__.py b/cbscore/src/cbscore/cmds/__init__.py index 04ff5a3f..85d38f3f 100644 --- a/cbscore/src/cbscore/cmds/__init__.py +++ b/cbscore/src/cbscore/cmds/__init__.py @@ -29,6 +29,7 @@ class Ctx: config_path: Path | None = None local: bool = False + dev: bool = False pass_ctx = click.make_pass_decorator(Ctx, ensure=True) diff --git a/cbscore/src/cbscore/cmds/builds.py b/cbscore/src/cbscore/cmds/builds.py index a9c90820..1bf12d74 100644 --- a/cbscore/src/cbscore/cmds/builds.py +++ b/cbscore/src/cbscore/cmds/builds.py @@ -203,6 +203,7 @@ def cmd_build( force=force, tls_verify=tls_verify, local=ctx.local, + dev=ctx.dev, ) ) @@ -326,6 +327,7 @@ def cmd_runner_build( force=force, tls_verify=tls_verify, local=ctx.local, + dev=ctx.dev, ) except BuilderError as e: logger.error(f"unable to initialize builder: {e}") diff --git a/cbscore/src/cbscore/runner.py b/cbscore/src/cbscore/runner.py index 89f233b3..97cb55a9 100644 --- a/cbscore/src/cbscore/runner.py +++ b/cbscore/src/cbscore/runner.py @@ -120,6 +120,7 @@ async def runner( force: bool = False, tls_verify: bool = True, local: bool = False, + dev: bool, ) -> None: our_actual_loc = Path(__file__).parent @@ -178,6 +179,7 @@ async def runner( force: {force} tls-verify: {tls_verify} push to s3: {not local} + development mode: {dev} """) if not entrypoint_path.exists() or not entrypoint_path.is_file(): @@ -291,6 +293,7 @@ async def runner( if logger.getEffectiveLevel() == logging.DEBUG else "0", "CBS_LOCAL": "1" if local else "0", + "CBS_DEV": "1" if dev else "0", }, args=podman_args, volumes=podman_volumes, diff --git a/cbscore/src/cbscore/utils/secrets/git.py b/cbscore/src/cbscore/utils/secrets/git.py index 23dbad8e..1cd5fdfd 100644 --- a/cbscore/src/cbscore/utils/secrets/git.py +++ b/cbscore/src/cbscore/utils/secrets/git.py @@ -162,7 +162,11 @@ def _ssh_git_url_for( def _https_git_url_for( - url: str, entry: GitHTTPSSecret | GitVaultHTTPSSecret, vault: Vault | None + url: str, + entry: GitHTTPSSecret | GitVaultHTTPSSecret, + vault: Vault | None, + *, + dev: bool = False, ) -> MaybeSecure: """Obtain URL for an HTTPS-based git access, either local or from vault.""" m = re.match(GIT_URL_PATTERN, url) @@ -174,6 +178,7 @@ def _https_git_url_for( https_host = cast(str, m.group("http_host")) https_port = cast(str, m.group("http_port")) if m.group("http_port") else "" http_path = cast(str, m.group("http_path")) + protocol = cast(str, m.group("http_protocol")) if dev else "https" if isinstance(entry, GitHTTPSSecret): username = entry.username @@ -201,7 +206,8 @@ def _https_git_url_for( raise SecretsMgrError(msg) from e return SecureURL( - "https://{username}:{password}@{host_with_port}/{path}", + "{protocol}://{username}:{password}@{host_with_port}/{path}", + protocol=protocol, username=username, password=Password(password), host_with_port=f"{https_host}{':' + https_port if https_port else ''}", @@ -232,7 +238,7 @@ def _token_git_url_for(url: str, entry: GitTokenSecret) -> MaybeSecure: @contextmanager def git_url_for( - url: str, secrets: dict[str, GitSecret], vault: Vault | None + url: str, secrets: dict[str, GitSecret], vault: Vault | None, *, dev: bool = False ) -> Generator[MaybeSecure]: """Obtain URL for git access.""" url_m = re.match(GIT_URL_PATTERN, url) @@ -257,7 +263,7 @@ def git_url_for( with _ssh_git_url_for(url, entry, vault) as ssh_url: yield ssh_url elif isinstance(entry, GitHTTPSSecret | GitVaultHTTPSSecret): - yield _https_git_url_for(url, entry, vault) + yield _https_git_url_for(url, entry, vault, dev=dev) else: # GitTokenSecret assert isinstance(entry, GitTokenSecret) yield _token_git_url_for(url, entry) diff --git a/cbscore/src/cbscore/utils/secrets/mgr.py b/cbscore/src/cbscore/utils/secrets/mgr.py index b05595cc..37b67854 100644 --- a/cbscore/src/cbscore/utils/secrets/mgr.py +++ b/cbscore/src/cbscore/utils/secrets/mgr.py @@ -66,9 +66,9 @@ def __init__( raise SecretsMgrError(msg) from e @contextmanager - def git_url_for(self, url: str) -> Generator[MaybeSecure]: + def git_url_for(self, url: str, *, dev: bool = False) -> Generator[MaybeSecure]: """Obtain git url with credentials for specified URL, if any.""" - with git_url_for(url, self.secrets.git, self.vault) as git_url: + with git_url_for(url, self.secrets.git, self.vault, dev=dev) as git_url: yield git_url def s3_creds(self, url: str) -> tuple[str, str, str]: From 4221c117fd13b1e1d309ab5b2d52755f3b9850af Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Mon, 23 Feb 2026 20:22:40 +0100 Subject: [PATCH 6/6] cbs: add integration test structure * what: add an integration test structure to the root project. add a resources folder inside the test directory. add an integration test for the cbscore build. * why: integration tests should be kept as global as possible. the test ensures the build process works correctly for local builds. Signed-off-by: Uwe Schwaeke --- .gitignore | 4 + pyproject.toml | 46 +++- tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/cbscore/__init__.py | 34 +++ tests/integration/cbscore/conftest.py | 260 ++++++++++++++++++ tests/integration/cbscore/test_build.py | 77 ++++++ tests/resources/cbscore/cbs-build.config.yaml | 24 ++ .../test-component/cbs.component.yaml | 25 ++ .../containers/99.99.1/container.yaml | 62 +++++ .../containers/99.99.1/setup-no-docs.sh | 19 ++ .../test-component/scripts/build_rpms.sh | 155 +++++++++++ .../test-component/scripts/get_release_rpm.sh | 19 ++ .../test-component/scripts/get_version.sh | 20 ++ .../test-component/scripts/install_deps.sh | 19 ++ tests/resources/cbscore/secrets.yaml | 26 ++ .../cbscore/test-component/test-component.sh | 15 + tests/resources/cbscore/version_desc.json | 22 ++ uv.lock | 147 ++++++++++ 19 files changed, 971 insertions(+), 3 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/cbscore/__init__.py create mode 100644 tests/integration/cbscore/conftest.py create mode 100644 tests/integration/cbscore/test_build.py create mode 100644 tests/resources/cbscore/cbs-build.config.yaml create mode 100644 tests/resources/cbscore/components/test-component/cbs.component.yaml create mode 100644 tests/resources/cbscore/components/test-component/containers/99.99.1/container.yaml create mode 100755 tests/resources/cbscore/components/test-component/containers/99.99.1/setup-no-docs.sh create mode 100755 tests/resources/cbscore/components/test-component/scripts/build_rpms.sh create mode 100755 tests/resources/cbscore/components/test-component/scripts/get_release_rpm.sh create mode 100755 tests/resources/cbscore/components/test-component/scripts/get_version.sh create mode 100755 tests/resources/cbscore/components/test-component/scripts/install_deps.sh create mode 100644 tests/resources/cbscore/secrets.yaml create mode 100644 tests/resources/cbscore/test-component/test-component.sh create mode 100644 tests/resources/cbscore/version_desc.json diff --git a/.gitignore b/.gitignore index ded99495..1c11b47f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ node_modules package.json yarn.lock .venv/ +!tests/resources/cbscore/cbs-build.config.yaml +.coverage +reports/ +coverage.xml \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 95282d8a..1789276d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,18 +4,58 @@ version = "1.0.4" description = "Clyso Enterprise Storage Build Service" readme = "README.md" requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "cbscore", +] [tool.uv.workspace] members = ["cbscore", "cbsdcore", "cbsd", "cbc", "crt"] +[tool.uv.sources] +cbscore = { workspace = true } + [dependency-groups] dev = ["basedpyright==1.37.1", "lefthook==2.0.15", "ruff==0.14.13"] -test = ["pytest>=8.0", "pytest-asyncio>=0.25"] +test = [ + "click>=8.1.8", + "httpx>=0.28.1", + "pytest>=8.0", + "pytest-asyncio>=0.25", + "pytest-cov>=7.0.0", + "testcontainers>=4.14.1", +] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["cbsd/tests"] +testpaths = ["cbsd/tests", "tests/integration"] +addopts = """ + --cov=cbc + --cov=cbscore + --cov=cbsd + --cov=cbsdcore + --cov=crt + --cov-report=term-missing + --cov-report=html:reports/coverage + --cov-report=xml:reports/coverage.xml +""" +markers = [ + "version: custom version for the test component ", + "container_name: name of the container image", + "container_tag: version tag used for the registry" +] + +[tool.coverage.run] +source = ["cbc", "cbscore", "cbsdcore"] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if __name__ == .__main__.:", + "raise NotImplementedError" +] [tool.ruff.lint] select = [ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/cbscore/__init__.py b/tests/integration/cbscore/__init__.py new file mode 100644 index 00000000..cd6c83e1 --- /dev/null +++ b/tests/integration/cbscore/__init__.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + + +import shutil +import subprocess +from pathlib import Path + + +def git(repo_path: Path, *args: str): + _ = _execute("git", "-C", str(repo_path), *args) + + +def podman(*args: str) -> str: + return _execute("podman", *args) + + +def _execute(executable: str, *args: str) -> str: + absolute_path = shutil.which(executable) + if not absolute_path: + raise RuntimeError(f"{executable} not found in PATH") + result = subprocess.run( # noqa: S603 - Arguments are controlled by the internal build logic + [absolute_path, *args], check=True, capture_output=True, text=True + ) + return result.stdout diff --git a/tests/integration/cbscore/conftest.py b/tests/integration/cbscore/conftest.py new file mode 100644 index 00000000..5bca3707 --- /dev/null +++ b/tests/integration/cbscore/conftest.py @@ -0,0 +1,260 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +import os +import shutil +import uuid +from collections.abc import Generator +from pathlib import Path +from string import Template + +import httpx +import pytest +from _pytest.fixtures import SubRequest +from testcontainers.core.container import ( # pyright: ignore[reportMissingTypeStubs] + DockerContainer, +) +from testcontainers.core.wait_strategies import ( # pyright: ignore[reportMissingTypeStubs] + HttpWaitStrategy, +) + +from tests.integration.cbscore import git, podman + +_username = "testuser" +_password = "testpass" # noqa: S105 it is intended to be hardcoded +_hashed_pass = "$apr1$PG4UgaB4$967wqWKtFcAkT/EG/SjcP1" # noqa: S105 it is intended to be hardcoded + + +@pytest.fixture(scope="session", autouse=True) +def configure_test_env() -> None: + """Configure the environment for podman and Testcontainers.""" + os.environ["TESTCONTAINERS_RYUK_DISABLED"] = "true" + + if "DOCKER_HOST" not in os.environ: + uid = os.getuid() + podman_sock = f"unix:///run/user/{uid}/podman/podman.sock" + os.environ["DOCKER_HOST"] = podman_sock + + os.environ["CBS_DEV"] = "true" + + +@pytest.fixture(scope="session") +def registry() -> Generator[str]: + """Start Docker Registry with Basic Auth.""" + # Pre-generated htpasswd for testuser:testpass + htpasswd_content = f"{_username}:{_hashed_pass}" + + registry = DockerContainer("registry:2").with_exposed_ports(5000) + + with registry: + wrapped = registry.get_wrapped_container() + _ = wrapped.exec_run("mkdir /auth") + _ = wrapped.exec_run(f"sh -c 'echo {htpasswd_content} > /auth/htpasswd'") + + host_ip: str = registry.get_container_host_ip() + host_port: int = int(registry.get_exposed_port(5000)) + + url: str = f"{host_ip}:{host_port}" + yield url + + +@pytest.fixture(scope="session") +def gitea() -> Generator[str]: + """Start a Gitea container and seeds it with a repository.""" + image_name = "docker.io/gitea/gitea:1.21-rootless" + _ = podman("pull", image_name) + + gitea = ( + DockerContainer(image_name) + .with_env("USER_UID", "1000") + .with_env("GITEA__security__INSTALL_LOCK", "true") # Skip setup + .with_exposed_ports(3000) + .waiting_for( + HttpWaitStrategy.from_url( + "http://0.0.0.0:3000/api/v1/version" + ).for_status_code(200) + ) + ) + + with gitea: + wrapped = gitea.get_wrapped_container() + _ = wrapped.exec_run( + f"gitea admin user create --username {_username} --password {_password} " + + "--email testuser@testcorp.com --admin --must-change-password=false" + ) + + host_ip = gitea.get_container_host_ip() + port = gitea.get_exposed_port(3000) + + yield f"{host_ip}:{port}" + + +class GitData: + repo_path: Path + repo_name: str + repo_url: str + + def __init__(self, repo_path: Path, repo_name: str, repo_url: str): + self.repo_path = repo_path + self.repo_name = repo_name + self.repo_url = repo_url + + +@pytest.fixture +def git_local_repo(tmp_path: Path, gitea: str) -> Generator[GitData]: + """Create a temporary local Git repository initialized with test data.""" + repo_path: Path = tmp_path / "test-component-repo" + repo_path.mkdir() + + resource_src: Path = ( + Path(__file__).parents[2] / "resources" / "cbscore" / "test-component" + ) + + _ = shutil.copytree(resource_src, repo_path, dirs_exist_ok=True) + + repo_name = f"test-repo-{uuid.uuid4()}" + + _ = httpx.post( + f"http://{gitea}/api/v1/user/repos", + auth=(_username, _password), + json={"name": repo_name, "private": False}, + ) + remote_url = f"http://{_username}:{_password}@{gitea}/{_username}/{repo_name}.git" + + git(repo_path, "init", "-b", "main") + git(repo_path, "config", "user.email", "testuser@testcorp.com") + git(repo_path, "config", "user.name", _username) + git(repo_path, "config", "commit.gpgsign", "false") + git(repo_path, "remote", "add", "origin", remote_url) + git(repo_path, "add", ".") + git(repo_path, "commit", "-m", "Initial commit for integration test") + git(repo_path, "push", "-u", "origin", "main") + + yield GitData(repo_path, repo_name, f"http://{gitea}/{_username}/{repo_name}.git") + + _ = httpx.delete( + f"http://{gitea}/api/v1/repos/{_username}/{repo_name}", + auth=(_username, _password), + ) + + +class ResourcePath: + config_path: Path + desc_path: Path + cbscore_path: Path + cbs_entrypoint_path: Path + + def __init__( + self, + config_path: Path, + desc_file: Path, + cbscore_path: Path, + cbs_entrypoint_path: Path, + ): + self.config_path = config_path + self.desc_path = desc_file + self.cbscore_path = cbscore_path + self.cbs_entrypoint_path = cbs_entrypoint_path + + +@pytest.fixture +def tmp_resources( + request: SubRequest, + tmp_path: Path, + registry: str, + gitea: str, + git_local_repo: GitData, +) -> ResourcePath: + version = _get_value_or(request, "version", default="tc-v99.99.1") + container_name = _get_value_or(request, "container_name", "tc") + container_tag = _get_value_or(request, "container_tag", "v99.99.1") + + resources = tmp_path / "resources" + resources.mkdir(parents=True, exist_ok=True) + + scratch = resources / "scratch" + scratch.mkdir(parents=True, exist_ok=True) + + scratch_container = resources / "scratch-container" + scratch_container.mkdir(parents=True, exist_ok=True) + + cbs = Path(__file__).parents[3] + + cbscore_resources = cbs / "tests" / "resources" / "cbscore" + + components = resources / "components" + _ = shutil.copytree( + cbscore_resources / "components", components, dirs_exist_ok=True + ) + + _ = _copy_file( + components / "test-component" / "cbs.component.yaml", + repo=git_local_repo.repo_url, + ) + + secrets_file = _copy_file( + cbscore_resources / "secrets.yaml", + resources, + reg_key=registry.split(":")[0], + reg_address=registry, + gitea_url=gitea, + ) + + desc_file = _copy_file( + cbscore_resources / "version_desc.json", + resources, + registry=registry, + git_repo=git_local_repo.repo_url, + version=version, + container_name=container_name, + container_tag=container_tag, + ) + + config_file = _copy_file( + cbscore_resources / "cbs-build.config.yaml", + resources, + components=components.absolute().as_posix(), + scratch=scratch.absolute().as_posix(), + scratch_containers=scratch_container.absolute().as_posix(), + secrets=str(secrets_file), + ) + + cbscore_path = cbs / "cbscore" + + cbs_entrypoint_path = ( + cbscore_path / "src" / "cbscore" / "_tools" / "cbscore-entrypoint.sh" + ) + + return ResourcePath(config_file, desc_file, cbscore_path, cbs_entrypoint_path) + + +@pytest.fixture(scope="session", autouse=True) +def podman_session_cleanup(): + yield + _ = podman("system", "prune", "--force", "--volumes") + + +def _copy_file(file: Path, dst: Path | None = None, **kwargs: str) -> Path: + txt = Template(file.read_text()) + txt = txt.safe_substitute(**kwargs) + if not dst: + dst = file.parent + name = file.name + ret = dst / name + + _ = ret.write_text(txt) + return ret + + +def _get_value_or[T](request: SubRequest, key: str, default: T) -> T: + marker = request.node.get_closest_marker(key) + return marker.args[0] if marker else default diff --git a/tests/integration/cbscore/test_build.py b/tests/integration/cbscore/test_build.py new file mode 100644 index 00000000..9301e658 --- /dev/null +++ b/tests/integration/cbscore/test_build.py @@ -0,0 +1,77 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +import httpx +import pytest +from cbscore.__main__ import cmd_main +from click.testing import CliRunner + +from tests.integration.cbscore import git, podman +from tests.integration.cbscore.conftest import GitData, ResourcePath + + +@pytest.mark.version("tc-v99.99.1") +@pytest.mark.container_name("tc") +@pytest.mark.container_tag("v99.99.1") +def test_cbscore_build( + registry: str, git_local_repo: GitData, tmp_resources: ResourcePath +): + # arrange + git(git_local_repo.repo_path, "switch", "-c", "release/tc-v99.99.1") + git(git_local_repo.repo_path, "tag", "-a", "tc-v99.99.1", "-m", '"test release"') + git( + git_local_repo.repo_path, + "push", + "--tags", + "-u", + "origin", + "release/tc-v99.99.1", + ) + + # act + runner = CliRunner() + result = runner.invoke( + cmd_main, + [ + "-c", + tmp_resources.config_path.absolute().as_posix(), + "-d", + "-l", + "build", + "--tls-verify=false", + "--cbscore-path", + tmp_resources.cbscore_path.absolute().as_posix(), + "-e", + tmp_resources.cbs_entrypoint_path.absolute().as_posix(), + tmp_resources.desc_path.absolute().as_posix(), + ], + ) + + # assert + assert result.exit_code == 0 + url = f"http://{registry}/v2/tc/manifests/v99.99.1" + auth = ("testuser", "testpass") + headers = { + "Accept": "application/vnd.oci.image.manifest.v1+json," + + "application/vnd.docker.distribution.manifest.v2+json" + } + response = httpx.get(url, auth=auth, headers=headers) + assert response.status_code == 200 + + _ = podman( + "run", "--rm", "--tls-verify=false", f"{registry}/tc:v99.99.1", "test-component" + ) + + image_ids = podman("images", "--filter=reference=tc", "--format={{.Id}}") + image_ids = [id.strip() for id in image_ids.splitlines() if id.strip()] + if image_ids: + _ = podman("rmi", *image_ids) diff --git a/tests/resources/cbscore/cbs-build.config.yaml b/tests/resources/cbscore/cbs-build.config.yaml new file mode 100644 index 00000000..f2c649c5 --- /dev/null +++ b/tests/resources/cbscore/cbs-build.config.yaml @@ -0,0 +1,24 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +logging: null +paths: + ccache: null + components: + - $components + scratch: $scratch + scratch-containers: $scratch_containers +secrets: +- $secrets +signing: null +storage: null +vault: null diff --git a/tests/resources/cbscore/components/test-component/cbs.component.yaml b/tests/resources/cbscore/components/test-component/cbs.component.yaml new file mode 100644 index 00000000..8e8ad48b --- /dev/null +++ b/tests/resources/cbscore/components/test-component/cbs.component.yaml @@ -0,0 +1,25 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +name: test-component +repo: $repo + +build: + rpm: + build: scripts/build_rpms.sh + release-rpm: scripts/get_release_rpm.sh + + get-version: scripts/get_version.sh + deps: scripts/install_deps.sh + +containers: + path: containers/ diff --git a/tests/resources/cbscore/components/test-component/containers/99.99.1/container.yaml b/tests/resources/cbscore/components/test-component/containers/99.99.1/container.yaml new file mode 100644 index 00000000..4fde5033 --- /dev/null +++ b/tests/resources/cbscore/components/test-component/containers/99.99.1/container.yaml @@ -0,0 +1,62 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +config: + env: + CEPH_IS_DEVEL: "False" + CEPH_REF: "{git_ref}" + CEPH_VERSION: "{git_ref}" + CEPH_OSD_FLAVOR: default + FROM_IMAGE: "{distro}" + labels: + ceph: "True" + CEPH_REF: "{git_ref}" + CEPH_SHA1: "{git_sha1}" + CEPH_GIT_REPO: "{git_repo_url}" + GANESHA_REPO_BASEURL: https://buildlogs.centos.org/centos/$releasever-stream/storage/$basearch/nfsganesha-5/ + OSD_FLAVOR: default + annotations: + com.clyso.ces.ceph.version: "{git_ref}" + +pre: + keys: [] + + packages: + - epel-release + - jq + - dnf-plugins-core + - {base_url}/components/test-component/rpm-{version}/el{el}.clyso/noarch/test-component-release-2-test.noarch.rpm + + repos: [] + + scripts: + - name: disable rpm docs install + run: setup-no-docs.sh + +packages: + required: + - section: test-component + packages: + - test-component + +post: + - name: ganesha-fixes + run: fix-ganesha.sh + + - name: fix-jaraco + run: fix-jaraco.sh + + - name: disable-sync-udev + run: disable-sync-udev.sh + + - name: cleanup + run: cleanup.sh diff --git a/tests/resources/cbscore/components/test-component/containers/99.99.1/setup-no-docs.sh b/tests/resources/cbscore/components/test-component/containers/99.99.1/setup-no-docs.sh new file mode 100755 index 00000000..6da78aea --- /dev/null +++ b/tests/resources/cbscore/components/test-component/containers/99.99.1/setup-no-docs.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +if grep -q 'tsflags' /etc/dnf/dnf.conf; then + sed -i 's/tsflags=.*/tsflags=nodocs/g' /etc/dnf/dnf.conf +else + echo "tsflags=nodocs" >>/etc/dnf/dnf.conf +fi diff --git a/tests/resources/cbscore/components/test-component/scripts/build_rpms.sh b/tests/resources/cbscore/components/test-component/scripts/build_rpms.sh new file mode 100755 index 00000000..43943c2f --- /dev/null +++ b/tests/resources/cbscore/components/test-component/scripts/build_rpms.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +build_test_comp_rpms() { + local test_comp="${1}" + local dist_version=".el${2}.clyso" + local topdir="${3:-${HOME}/rpmbuild}" + local version="${4}" + + for i in BUILD SOURCES RPMS SRPMS SPECS; do + mkdir -p "${topdir}/${i}" || true + done + + + echo "Build test_comp SRPMs and RPMs" + + cp $test_comp/test-component.sh ${topdir}/SOURCES/test-component.sh + + cat <"${topdir}"/SPECS/test-component.spec +Name: test-component +Version: ${version} +Release: 1%{?dist} +Summary: A simple bash script +License: GPL +BuildArch: noarch + +Source0: test-component.sh + +%description +This package installs a simple hello world. + +%prep +# No tarball to unpack, so we just copy the sources to the BUILD directory +%setup -q -c -T +cp %{SOURCE0} . + +%install +# 1. Install the script to /usr/bin +mkdir -p %{buildroot}%{_bindir} +install -m 755 test-component.sh %{buildroot}%{_bindir}/test-component + +%files +%{_bindir}/test-component + +%changelog +* Mon Feb 23 2026 Your Name - 1.0.0-1 +- Initial build of the hello world tool +EOF + + echo "building" + rpmbuild \ + --define "_topdir ${topdir}" \ + --define "dist ${dist_version}" \ + -bb "${topdir}"/SPECS/test-component.spec || exit 1 +} + +# This function was initially copied, and then heavily based, on the function +# of the same name from 'ceph/ceph-build', in 'ceph-rpm-release/build/build'. +# It has been significantly modified for CES purposes instead of upstream's. +build_test_comp_release_rpm() { + local el_version="${2}" + local topdir="${3}" + local version="${4}" + local base_url=$"${5:-https://s3.test-corp.com/test_comp-packages}" + local gpgcheck=$"${6:-1}" + + echo "Build test_comp RPM release package" + + summary="test-component repository configuration" + project_url=https://www.clyso.com/ + epoch=1 # means a non-development release (0 would be development) + target="el${el_version}.clyso" + repo_base_url="${base_url}/components/test-component/rpm-${version}/${target}" + gpgkey=https://s3.test_comp.com/test_comp-packages/release.asc + dist_version=".el${el_version}.clyso" + + cat <"${topdir}"/SPECS/test-component-release.spec +Name: test-component-release +Version: 2 +Release: test +Summary: summary +Group: System Environment/Base +License: GPLv2 +URL: http://test-corp.org +Source0: test-component-release.repo +BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) +BuildArch: noarch + +%description +This package contains the test-component-release repository's +configuration for yum and up2date. + +%prep + +%setup -q -c -T +install -pm 644 %{SOURCE0} . + +%build + +%install +rm -rf %{buildroot} +install -dm 755 %{buildroot}/%{_sysconfdir}/yum.repos.d +install -pm 644 %{SOURCE0} \ + %{buildroot}/%{_sysconfdir}/yum.repos.d + +%clean + +%post + +%postun + +%files +%defattr(-,root,root,-) +/etc/yum.repos.d/* + +%changelog +* Mon Feb 23 2026 Your Name - 1.0.0-1 +- Initial Package +EOF + # End of ceph-release.spec file. + + # Install ceph.repo file + cat <"${topdir}"/SOURCES/test-component-release.repo +[test-component-noarch] +name=test-component noarch packages +baseurl=${repo_base_url}/noarch +enabled=1 +gpgcheck=${gpgcheck} +type=rpm-md +gpgkey=${gpgkey} + +EOF + # End of ceph.repo file + + # build source packages for official releases + rpmbuild -ba \ + --define "_topdir ${topdir}" \ + --define "_unpackaged_files_terminate_build 0" \ + --define "dist ${dist_version}" \ + "${topdir}"/SPECS/test-component-release.spec +} + +build_test_comp_rpms "$@" || exit 1 +build_test_comp_release_rpm "$@" || exit 1 diff --git a/tests/resources/cbscore/components/test-component/scripts/get_release_rpm.sh b/tests/resources/cbscore/components/test-component/scripts/get_release_rpm.sh new file mode 100755 index 00000000..7c7f8976 --- /dev/null +++ b/tests/resources/cbscore/components/test-component/scripts/get_release_rpm.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +el_version="${1}" + +: "${el_version:?}" + +echo "noarch/test_comp-release-2-1.el${el_version}.test_corp.noarch.rpm" diff --git a/tests/resources/cbscore/components/test-component/scripts/get_version.sh b/tests/resources/cbscore/components/test-component/scripts/get_version.sh new file mode 100755 index 00000000..8a23222f --- /dev/null +++ b/tests/resources/cbscore/components/test-component/scripts/get_version.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +get_version() { + git describe --long --match 'tc-v*' 2>/dev/null | sed -E 's/tc-v([0-9.]+)-.*/\1/' + echo "${version}" +} + +get_version diff --git a/tests/resources/cbscore/components/test-component/scripts/install_deps.sh b/tests/resources/cbscore/components/test-component/scripts/install_deps.sh new file mode 100755 index 00000000..0db77ecd --- /dev/null +++ b/tests/resources/cbscore/components/test-component/scripts/install_deps.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +test_comp_install_deps() { + return 0 +} + +test_comp_install_deps "$@" diff --git a/tests/resources/cbscore/secrets.yaml b/tests/resources/cbscore/secrets.yaml new file mode 100644 index 00000000..4198d8c7 --- /dev/null +++ b/tests/resources/cbscore/secrets.yaml @@ -0,0 +1,26 @@ +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +git: + '$gitea_url/testuser': + creds: plain + username: testuser + password: testpass + +storage: {} +sign: {} +registry: + '$reg_key': + creds: plain + username: testuser + password: testpass + address: $reg_address diff --git a/tests/resources/cbscore/test-component/test-component.sh b/tests/resources/cbscore/test-component/test-component.sh new file mode 100644 index 00000000..55552ac9 --- /dev/null +++ b/tests/resources/cbscore/test-component/test-component.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Copyright (C) 2025 Clyso GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +echo "Hello World" \ No newline at end of file diff --git a/tests/resources/cbscore/version_desc.json b/tests/resources/cbscore/version_desc.json new file mode 100644 index 00000000..536a32a4 --- /dev/null +++ b/tests/resources/cbscore/version_desc.json @@ -0,0 +1,22 @@ +{ + "version": "$version", + "title": "Release Development TC version $version", + "signed_off_by": { + "user": "testuser", + "email": "testuser@testcorp.com" + }, + "image": { + "registry": "$registry", + "name": "$container_name", + "tag": "$container_tag" + }, + "components": [ + { + "name": "test-component", + "repo": "$git_repo", + "ref": "$version" + } + ], + "distro": "rockylinux:9", + "el_version": 9 +} diff --git a/uv.lock b/uv.lock index 54e45094..e9ffe32c 100644 --- a/uv.lock +++ b/uv.lock @@ -653,6 +653,9 @@ wheels = [ name = "clyso-cbs" version = "1.0.4" source = { virtual = "." } +dependencies = [ + { name = "cbscore" }, +] [package.dev-dependencies] dev = [ @@ -661,11 +664,16 @@ dev = [ { name = "ruff" }, ] test = [ + { name = "click" }, + { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "testcontainers" }, ] [package.metadata] +requires-dist = [{ name = "cbscore", editable = "cbscore" }] [package.metadata.requires-dev] dev = [ @@ -674,8 +682,12 @@ dev = [ { name = "ruff", specifier = "==0.14.13" }, ] test = [ + { name = "click", specifier = ">=8.1.8" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.25" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "testcontainers", specifier = ">=4.14.1" }, ] [[package]] @@ -687,6 +699,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + [[package]] name = "croniter" version = "6.0.0" @@ -778,6 +859,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "fastapi" version = "0.128.0" @@ -1527,6 +1622,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1539,6 +1648,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -1548,6 +1666,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1693,6 +1824,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + [[package]] name = "tornado" version = "6.5.4"