diff --git a/.github/workflows/cicd_6-release.yml b/.github/workflows/cicd_6-release.yml index 239933a4e4c9..3e76eaba56b8 100644 --- a/.github/workflows/cicd_6-release.yml +++ b/.github/workflows/cicd_6-release.yml @@ -139,7 +139,11 @@ jobs: tag-identifier: ${{ needs.release-prepare.outputs.release_version }} omit-environment-prefix: true artifact-run-id: ${{ github.run_id }} - latest: ${{ needs.release-prepare.outputs.is_latest == 'true' && github.ref_name == 'main' && github.event.inputs.update_latest == 'true' }} + # `latest` is no longer moved here. evergreen-tracks is the single controller + # of the floating latest/standard/trailing tags; the promote-latest job below + # repoints `latest` on-demand once the release images are published (and honors + # the `update_latest` opt-out for back-patches of older release lines). + latest: false deploy-cli: true deploy-dev-image: true publish-npm-cli: false @@ -154,6 +158,50 @@ jobs: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} DEV_REQUEST_TOKEN: ${{ secrets.DEV_REQUEST_TOKEN }} + # Promote latest - move the floating `latest` Docker tag to this GA release. + # + # evergreen-tracks (cicd/evergreen-tracks) is the single controller of the + # latest/standard/trailing tags. Its daily cron ages standard/trailing, but + # `latest` must move the instant a GA ships — so the release pipeline invokes + # the same engine on-demand, scoped to the latest track only (--tracks latest). + # This replaces the old deploy-docker `latest: true` path (now `latest: false`). + # + # Applies for any real latest release from main on dotcms/core. + promote-latest: + name: Promote latest tag + # Default success() gate: only runs when release-prepare AND deployment both + # succeeded, so the release images are guaranteed to exist before we repoint. + needs: [ release-prepare, deployment ] + if: >- + needs.release-prepare.outputs.is_latest == 'true' + && github.ref_name == 'main' + && github.repository == 'dotcms/core' + && github.event.inputs.update_latest == 'true' + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + permissions: + contents: read + # Share the registry-mutation lock with the promote/admin workflows so this + # on-demand promote can never race the daily cron or an admin run. + concurrency: + group: evergreen-tracks-registry + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: docker/setup-buildx-action@v3 + - name: Docker login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Move latest to ${{ needs.release-prepare.outputs.release_version }} + working-directory: cicd/evergreen-tracks + run: | + for repo in dotcms/dotcms dotcms/dotcms-dev; do + echo "--- promoting latest on ${repo} ---" + uv run evergreen-tracks promote --repo "${repo}" --tracks latest --apply + done + # Release - release-specific operations (Artifactory, Javadocs, Plugins, SBOM, Labels) # Waits for deployment to complete to safely update labels only if both succeed release: diff --git a/.github/workflows/cicd_evergreen-tracks-admin.yml b/.github/workflows/cicd_evergreen-tracks-admin.yml new file mode 100644 index 000000000000..12a10f5a7ed9 --- /dev/null +++ b/.github/workflows/cicd_evergreen-tracks-admin.yml @@ -0,0 +1,71 @@ +name: evergreen-tracks-admin +on: + workflow_dispatch: + inputs: + repo: + description: 'Docker repo to operate on' + required: true + default: 'dotcms/dotcms' + action: + description: 'taint | untaint | hold | release-hold' + required: true + type: choice + options: [taint, untaint, hold, release-hold] + version: + description: 'GA version (for taint/untaint/hold)' + required: false + default: '' + track: + description: 'Track (for hold/release-hold): latest|standard|trailing' + required: false + default: '' + force: + description: 'Allow holding onto a tainted version' + required: false + default: 'false' + apply: + description: 'Actually mutate tags (false = dry-run)' + required: true + default: 'false' + +# Share the registry-mutation lock with the promote workflows so an admin +# taint/hold can never overlap a promote run on the same tags. +concurrency: + group: evergreen-tracks-registry + cancel-in-progress: false + +jobs: + admin: + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: docker/setup-buildx-action@v3 + - name: Docker login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Run admin action + working-directory: cicd/evergreen-tracks + env: + # Scope Docker Hub creds to this step only (the CLI's untaint/release-hold + # delete calls read them); other steps/actions don't need them. + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + REPO: ${{ github.event.inputs.repo }} + ACTION: ${{ github.event.inputs.action }} + VERSION: ${{ github.event.inputs.version }} + TRACK: ${{ github.event.inputs.track }} + FORCE: ${{ github.event.inputs.force }} + APPLY: ${{ github.event.inputs.apply }} + run: | + EXTRA="" + if [ "$FORCE" = "true" ]; then EXTRA="$EXTRA --force"; fi + if [ "$APPLY" = "true" ]; then EXTRA="$EXTRA --apply"; fi + uv run evergreen-tracks admin \ + --repo "$REPO" \ + --action "$ACTION" \ + --version "$VERSION" \ + --track "$TRACK" \ + $EXTRA diff --git a/.github/workflows/cicd_evergreen-tracks-promote.yml b/.github/workflows/cicd_evergreen-tracks-promote.yml new file mode 100644 index 000000000000..d78019fdbf26 --- /dev/null +++ b/.github/workflows/cicd_evergreen-tracks-promote.yml @@ -0,0 +1,76 @@ +name: evergreen-tracks-promote +on: + schedule: + - cron: '17 6 * * *' # daily at 06:17 UTC + workflow_dispatch: + inputs: + repo: + description: 'Docker repo to operate on' + required: true + default: 'dotcms/dotcms' + apply: + description: 'Actually move tags (false = dry-run)' + required: true + default: 'false' + standard_days: + description: 'Min age (days) for the standard track' + required: false + default: '14' + trailing_days: + description: 'Min age (days) for the trailing track' + required: false + default: '28' + +permissions: + contents: read + +# Serialize every registry mutation. The daily cron, a manual promote/admin +# dispatch, and the on-demand latest-promote in the release pipeline all read +# live registry state and then apply tag moves, so two concurrent runs could act +# on stale state and overwrite a hold/taint or move a track based on outdated +# data. A single static group (not keyed by workflow/ref) makes them serialize +# across all three workflows. For a tag-promotion engine queueing is safer than +# cancelling, so cancel-in-progress is false: concurrent runs wait their turn +# rather than aborting mid-promote and leaving tags half-moved. +concurrency: + group: evergreen-tracks-registry + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: docker/setup-buildx-action@v3 + - name: Docker login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Promote tracks + working-directory: cicd/evergreen-tracks + env: + # Scheduled runs default to dotcms/dotcms; dispatch can override. + REPO: ${{ github.event.inputs.repo || 'dotcms/dotcms' }} + STANDARD_DAYS: ${{ github.event.inputs.standard_days || '14' }} + TRAILING_DAYS: ${{ github.event.inputs.trailing_days || '28' }} + EVENT_NAME: ${{ github.event_name }} + DISPATCH_APPLY: ${{ github.event.inputs.apply }} + run: | + # Scheduled (cron) runs always apply — this is the engine that ages + # standard/trailing forward. To pause promotion, disable this workflow + # in the Actions tab or `hold` the affected track; there is no separate + # apply gate. Manual dispatch defaults to dry-run unless apply=true. + APPLY="" + if [ "$EVENT_NAME" = "schedule" ]; then + APPLY="--apply" + elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DISPATCH_APPLY" = "true" ]; then + APPLY="--apply" + fi + echo "event=$EVENT_NAME repo=$REPO apply=${APPLY:-}" + uv run evergreen-tracks promote \ + --repo "$REPO" \ + --standard-days "$STANDARD_DAYS" \ + --trailing-days "$TRAILING_DAYS" \ + $APPLY diff --git a/README.md b/README.md index b58b3f5dc28a..9a871f49024e 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,43 @@ For a complete list of requirements, see [this page](http://www.dotcms.com/docs/ | Forums/Listserv | [via Google Groups](https://groups.google.com/forum/#!forum/dotCMS) | | Twitter | @dotCMS | | Main Site | [dotCMS.com](https://www.dotcms.com/) | + +## Release Tracks + +dotCMS GA releases follow CalVer (`YY.0M.0D-NN`, e.g. `26.06.11-01`). On top of that linear +release stream we publish three **floating Docker tags** — *release tracks* — so you can pick +how fresh a release each environment receives: + +| Tag | Age of release | Use for | +| ------------------------- | ----------------- | ----------------------------------------- | +| `dotcms/dotcms:latest` | newest GA (~days) | tracking the latest release | +| `dotcms/dotcms:standard` | ~2 weeks old | a short soak before adopting a release | +| `dotcms/dotcms:trailing` | ~4 weeks old | the most conservative posture | + +Pin the track you want in your deployment manifest, e.g. `image: dotcms/dotcms:standard`, and +you will roll forward automatically as releases age into that track. Pin an exact version +(`dotcms/dotcms:26.06.11-01`) instead if you never want automatic movement. + +One engine (under [`cicd/evergreen-tracks/`](cicd/evergreen-tracks/)) re-points every track tag +by image digest, on two triggers: the release pipeline moves `latest` on-demand the moment a GA +ships, and a daily scheduled job ages `standard`/`trailing` forward. Two design choices are worth +understanding: + +- **Age is measured from the CalVer date in the version string, not from when the image was + built or published.** This protects emergency backports: if we cut a patch of an *older* + release on short notice, it is built today but logically belongs to the older release line. + Using the build/publish date would make that patch look brand new and let it jump onto the + `standard`/`trailing` tracks ahead of releases that are genuinely older. Anchoring to the + embedded CalVer date keeps every release in its true place on the timeline. +- **A track never moves backward automatically, and a release can be "tainted".** If a bad + release is found, it is tainted so it will not advance onto tracks it has not yet reached — + a known-bad build can never propagate from `latest` down to `standard`/`trailing`. A track + can also be **held** (frozen) at a specific version for incident response. Both controls live + as marker tags in the registry; there is no separate datastore. + +> **GitOps / Argo note:** because these tags float (the same tag is re-pointed to newer +> digests over time), a tag reference alone will not trigger a redeploy. Use Argo CD Image +> Updater or a periodic rollout refresh to pick up track movements. + +The promotion engine and its scheduled/admin workflows live under +[`cicd/evergreen-tracks/`](cicd/evergreen-tracks/). diff --git a/cicd/evergreen-tracks/README.md b/cicd/evergreen-tracks/README.md new file mode 100644 index 000000000000..605453350ee4 --- /dev/null +++ b/cicd/evergreen-tracks/README.md @@ -0,0 +1,16 @@ +# evergreen-tracks + +Advances floating Docker track tags (`latest`, `standard`, `trailing`) across the dotCMS +GA CalVer release stream by release age. State lives entirely in registry tags (the track +tags plus `_tainted` / `_hold` markers). + +## Run locally (dry-run is the default) + + uv run evergreen-tracks promote --repo dotcms/dotcms-test + uv run evergreen-tracks admin --repo dotcms/dotcms-test --action taint --version 26.03.12-01 + +Pass `--apply` to actually move tags. Without it, the command prints the plan and exits. + +## Test + + uv run pytest diff --git a/cicd/evergreen-tracks/pyproject.toml b/cicd/evergreen-tracks/pyproject.toml new file mode 100644 index 000000000000..d9a2733c4136 --- /dev/null +++ b/cicd/evergreen-tracks/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "evergreen-tracks" +version = "0.1.0" +description = "Floating Docker track tags (latest/standard/trailing) for dotCMS releases" +requires-python = ">=3.12" +dependencies = [ + "requests>=2.32", +] + +[project.scripts] +evergreen-tracks = "evergreen_tracks.cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "responses>=0.25", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/evergreen_tracks"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/__init__.py b/cicd/evergreen-tracks/src/evergreen_tracks/__init__.py new file mode 100644 index 000000000000..640eb2ae09eb --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/__init__.py @@ -0,0 +1,3 @@ +"""Evergreen release tracks: floating Docker tags advanced by release age.""" + +__all__ = [] diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/calver.py b/cicd/evergreen-tracks/src/evergreen_tracks/calver.py new file mode 100644 index 000000000000..c295548c2a36 --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/calver.py @@ -0,0 +1,42 @@ +"""CalVer release parsing, age, and ordering. No I/O.""" +from __future__ import annotations + +import datetime as dt +import re +from dataclasses import dataclass + +# GA tags only: YY.0M.0D-NN (e.g. 26.06.11-01). Excludes SNAPSHOT, RC, _lts*, _javaNN, markers. +GA_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{2})-(\d{1,2})$") + + +@dataclass(frozen=True) +class Release: + version: str # original tag, e.g. "26.06.11-01" + date: dt.date # parsed from YY.0M.0D + build: int # NN + + @property + def sort_key(self) -> tuple[dt.date, int]: + return (self.date, self.build) + + +def parse_release(tag: str) -> Release | None: + """Return a Release for a GA tag, or None for anything else.""" + m = GA_RE.match(tag) + if not m: + return None + yy, mm, dd, nn = m.groups() + try: + date = dt.date(2000 + int(yy), int(mm), int(dd)) + except ValueError: + return None + return Release(version=tag, date=date, build=int(nn)) + + +def age_days(release: Release, today: dt.date) -> int: + return (today - release.date).days + + +def newest(releases: list[Release]) -> Release | None: + """Newest by (date, build); None if the list is empty.""" + return max(releases, key=lambda r: r.sort_key, default=None) diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/cli.py b/cicd/evergreen-tracks/src/evergreen_tracks/cli.py new file mode 100644 index 000000000000..1b9f2933f983 --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/cli.py @@ -0,0 +1,174 @@ +"""CLI entrypoint: `evergreen-tracks promote` and `evergreen-tracks admin`. + +Dry-run is the default everywhere. Pass --apply to mutate the registry. +""" +from __future__ import annotations + +import argparse +import datetime as dt +import logging +import os +import sys + +from .calver import newest, parse_release +from .executor import delete_tag, hub_login, point_tag +from .markers import TRACKS, held_tracks, hold_tag, tainted_versions, taint_tag +from .planner import TrackState, plan +from .registry import list_tags + +log = logging.getLogger("evergreen_tracks") + + +def _state(repo: str): + """Return (releases, tainted, held, name->digest) read from the registry.""" + tags = list_tags(repo) + names = [t.name for t in tags] + digests = {t.name: t.digest for t in tags} + releases = [r for r in (parse_release(n) for n in names) if r is not None] + return releases, tainted_versions(names), held_tracks(names), digests + + +def _current_version(track: str, digests: dict[str, str], releases) -> str | None: + """Which GA version the floating tag currently points at, by digest match.""" + track_digest = digests.get(track) + if not track_digest: + return None + matches = [r for r in releases if digests.get(r.version) == track_digest] + if not matches: + return None + return newest(matches).version + + +def cmd_promote(args: argparse.Namespace) -> int: + releases, tainted, held, digests = _state(args.repo) + tracks = [ + TrackState("latest", args.latest_days, _current_version("latest", digests, releases)), + TrackState("standard", args.standard_days, _current_version("standard", digests, releases)), + TrackState("trailing", args.trailing_days, _current_version("trailing", digests, releases)), + ] + + # Optional subset (e.g. --tracks latest): the release pipeline invokes this + # engine on-demand to move only `latest` the instant a GA ships, while the + # daily cron ages standard/trailing. One engine, two triggers. + if args.tracks: + wanted = {t.strip() for t in args.tracks.split(",") if t.strip()} + unknown = wanted - set(TRACKS) + if unknown: + log.error("unknown track(s): %s", ", ".join(sorted(unknown))) + return 2 + tracks = [t for t in tracks if t.name in wanted] + held = held & wanted + + moves = plan(releases, tainted, held, tracks, today=dt.date.today()) + + # Held tracks are frozen against promotion; instead reconcile the floating + # tag to its _hold marker digest so a divergence self-heals. + for track in held: + marker = hold_tag(track) + hold_digest = digests.get(marker) + if hold_digest is None: + continue + if digests.get(track) == hold_digest: + log.info("%s: held at %s, skipping promotion", track, marker) + continue + log.info("%s (held) -> reconcile to %s (%s)", track, marker, hold_digest) + point_tag(args.repo, track, hold_digest, apply=args.apply) + + if not moves: + log.info("no track moves needed") + return 0 + for m in moves: + digest = digests[m.target_version] + log.info("%s -> %s (%s)", m.track, m.target_version, digest) + point_tag(args.repo, m.track, digest, apply=args.apply) + return 0 + + +def _delete_marker(repo: str, marker: str, *, apply: bool) -> int: + """Delete a marker tag. Only logs into Hub when applying, so dry-runs need no creds.""" + token = "" + if apply: + username = os.environ.get("DOCKER_USERNAME") + token_val = os.environ.get("DOCKER_TOKEN") + if not username or not token_val: + log.error("DOCKER_USERNAME and DOCKER_TOKEN must be set to apply this action") + return 2 + token = hub_login(username, token_val) + delete_tag(repo, marker, token, apply=apply) + return 0 + + +def cmd_admin(args: argparse.Namespace) -> int: + releases, tainted, held, digests = _state(args.repo) + + if args.action in ("taint", "untaint"): + if not parse_release(args.version): + log.error("not a GA version: %s", args.version) + return 2 + marker = taint_tag(args.version) + if args.action == "taint": + if args.version not in digests: + log.error("version %s not found in %s", args.version, args.repo) + return 2 + point_tag(args.repo, marker, digests[args.version], apply=args.apply) + else: + return _delete_marker(args.repo, marker, apply=args.apply) + return 0 + + if args.action in ("hold", "release-hold"): + if args.track not in TRACKS: + log.error("unknown track: %s", args.track) + return 2 + marker = hold_tag(args.track) + if args.action == "hold": + if not parse_release(args.version) or args.version not in digests: + log.error("hold needs an existing GA --version; got %s", args.version) + return 2 + if args.version in tainted and not args.force: + log.error("refusing to hold %s onto tainted %s (use --force)", + args.track, args.version) + return 2 + point_tag(args.repo, marker, digests[args.version], apply=args.apply) + point_tag(args.repo, args.track, digests[args.version], apply=args.apply) + else: + return _delete_marker(args.repo, marker, apply=args.apply) + return 0 + + log.error("unknown action: %s", args.action) + return 2 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="evergreen-tracks") + sub = p.add_subparsers(dest="command", required=True) + + pr = sub.add_parser("promote", help="advance track tags by release age") + pr.add_argument("--repo", required=True) + pr.add_argument("--apply", action="store_true", help="actually mutate the registry") + pr.add_argument("--tracks", default="", + help="comma-separated subset to move (latest,standard,trailing); default all") + pr.add_argument("--latest-days", type=int, default=0) + pr.add_argument("--standard-days", type=int, default=14) + pr.add_argument("--trailing-days", type=int, default=28) + pr.set_defaults(func=cmd_promote) + + ad = sub.add_parser("admin", help="taint / untaint / hold / release-hold") + ad.add_argument("--repo", required=True) + ad.add_argument("--apply", action="store_true", help="actually mutate the registry") + ad.add_argument("--action", required=True, + choices=["taint", "untaint", "hold", "release-hold"]) + ad.add_argument("--version", default="") + ad.add_argument("--track", default="") + ad.add_argument("--force", action="store_true") + ad.set_defaults(func=cmd_admin) + return p + + +def main(argv: list[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(message)s") + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/executor.py b/cicd/evergreen-tracks/src/evergreen_tracks/executor.py new file mode 100644 index 000000000000..19bdae8ce6ce --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/executor.py @@ -0,0 +1,60 @@ +"""Write side: move a track tag to a digest, and create/delete marker tags. + +Moves use `docker buildx imagetools create`, which re-points a tag to an existing +digest without re-pushing layers. Deletes use the Docker Hub API (needs a JWT). +Every mutating call respects `apply`: when False it logs the intended action only. +""" +from __future__ import annotations + +import logging +import subprocess + +import requests + +_HUB = "https://hub.docker.com/v2" +_TIMEOUT = 30 +log = logging.getLogger("evergreen_tracks.executor") + + +def point_tag(repo: str, tag: str, digest: str, *, apply: bool) -> None: + """Point repo:tag at repo@digest.""" + src = f"{repo}@{digest}" + dst = f"{repo}:{tag}" + if not apply: + log.info("DRY-RUN would point %s -> %s", dst, digest) + return + log.info("pointing %s -> %s", dst, digest) + subprocess.run( + ["docker", "buildx", "imagetools", "create", "-t", dst, src], + check=True, + ) + + +def hub_login(username: str, password: str) -> str: + """Return a Hub JWT for delete calls.""" + resp = requests.post( + f"{_HUB}/users/login", + json={"username": username, "password": password}, + timeout=_TIMEOUT, + ) + resp.raise_for_status() + body = resp.json() + if "token" not in body: + raise RuntimeError( + f"Hub login succeeded (HTTP {resp.status_code}) but response contained no 'token' key. " + f"Response body: {body!r}" + ) + return body["token"] + + +def delete_tag(repo: str, tag: str, token: str, *, apply: bool) -> None: + """Delete repo:tag via the Hub API (used for untaint / release-hold / teardown).""" + namespace, name = repo.split("/", 1) + url = f"{_HUB}/repositories/{namespace}/{name}/tags/{tag}/" + if not apply: + log.info("DRY-RUN would delete tag %s:%s", repo, tag) + return + log.info("deleting tag %s:%s", repo, tag) + resp = requests.delete(url, headers={"Authorization": f"JWT {token}"}, timeout=_TIMEOUT) + if resp.status_code not in (200, 202, 204, 404): + resp.raise_for_status() diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/markers.py b/cicd/evergreen-tracks/src/evergreen_tracks/markers.py new file mode 100644 index 000000000000..caad7ef7ed68 --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/markers.py @@ -0,0 +1,36 @@ +"""Marker-tag naming and parsing. State lives in registry tags. No I/O.""" +from __future__ import annotations + +# Track names, conservative -> fresh order is irrelevant here; this is the closed set. +TRACKS = ("latest", "standard", "trailing") + +_TAINT_SUFFIX = "_tainted" +_HOLD_SUFFIX = "_hold" + + +def taint_tag(version: str) -> str: + return f"{version}{_TAINT_SUFFIX}" + + +def hold_tag(track: str) -> str: + return f"{track}{_HOLD_SUFFIX}" + + +def tainted_versions(tags: list[str]) -> set[str]: + """Versions quarantined from advancing, derived from _tainted markers.""" + return { + t[: -len(_TAINT_SUFFIX)] + for t in tags + if t.endswith(_TAINT_SUFFIX) + } + + +def held_tracks(tags: list[str]) -> set[str]: + """Tracks frozen by a _hold marker. Unknown track names are ignored.""" + out = set() + for t in tags: + if t.endswith(_HOLD_SUFFIX): + name = t[: -len(_HOLD_SUFFIX)] + if name in TRACKS: + out.add(name) + return out diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/planner.py b/cicd/evergreen-tracks/src/evergreen_tracks/planner.py new file mode 100644 index 000000000000..3644851a87fe --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/planner.py @@ -0,0 +1,57 @@ +"""Pure promotion engine: decide each track's target release. No I/O.""" +from __future__ import annotations + +import datetime as dt +from dataclasses import dataclass + +from .calver import Release, age_days, newest + + +@dataclass(frozen=True) +class TrackState: + name: str # "latest" | "standard" | "trailing" + threshold_days: int # minimum release age to be eligible + current_version: str | None # version the track tag points at now, or None + + +@dataclass(frozen=True) +class Move: + track: str + target_version: str + + +def plan( + releases: list[Release], + tainted: set[str], + held: set[str], + tracks: list[TrackState], + today: dt.date, +) -> list[Move]: + """Return the tag moves to apply. Held tracks are frozen (no move emitted). + + Rules: + - eligible = age >= threshold AND version not tainted + - target = newest eligible + - forward-only: only move if target is strictly newer than current + """ + by_version = {r.version: r for r in releases} + moves: list[Move] = [] + + for t in tracks: + if t.name in held: + continue # frozen; executor reconciles track tag to the hold marker separately + + eligible = [ + r for r in releases + if age_days(r, today) >= t.threshold_days and r.version not in tainted + ] + target = newest(eligible) + if target is None: + continue + + current = by_version.get(t.current_version) if t.current_version else None + if current is None or target.sort_key > current.sort_key: + if target.version != t.current_version: + moves.append(Move(track=t.name, target_version=target.version)) + + return moves diff --git a/cicd/evergreen-tracks/src/evergreen_tracks/registry.py b/cicd/evergreen-tracks/src/evergreen_tracks/registry.py new file mode 100644 index 000000000000..4d0cdae65829 --- /dev/null +++ b/cicd/evergreen-tracks/src/evergreen_tracks/registry.py @@ -0,0 +1,41 @@ +"""Read side: list tags and digests from the Docker Hub API. Public repos need no auth.""" +from __future__ import annotations + +from dataclasses import dataclass + +import requests + +_HUB = "https://hub.docker.com/v2" +_TIMEOUT = 30 + + +@dataclass(frozen=True) +class Tag: + name: str + digest: str + + +def _digest_of(result: dict) -> str | None: + if result.get("digest"): + return result["digest"] + images = result.get("images") or [] + if images and images[0].get("digest"): + return images[0]["digest"] + return None + + +def list_tags(repo: str) -> list[Tag]: + """All tags in the repo with their manifest digests, following pagination.""" + namespace, name = repo.split("/", 1) + url = f"{_HUB}/namespaces/{namespace}/repositories/{name}/tags?page_size=100" + out: list[Tag] = [] + while url: + resp = requests.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + body = resp.json() + for result in body.get("results", []): + digest = _digest_of(result) + if result.get("name") and digest: + out.append(Tag(name=result["name"], digest=digest)) + url = body.get("next") + return out diff --git a/cicd/evergreen-tracks/tests/fixtures/hub_tags.json b/cicd/evergreen-tracks/tests/fixtures/hub_tags.json new file mode 100644 index 000000000000..4e6dc95247b5 --- /dev/null +++ b/cicd/evergreen-tracks/tests/fixtures/hub_tags.json @@ -0,0 +1 @@ +{"count":2,"next":null,"previous":null,"results":[{"creator":924487,"id":1100435257,"images":[{"architecture":"amd64","features":"","variant":null,"digest":"sha256:3e94fbb14cba73fdd3ceec52de7b9b687f9f2e37d420f5827de9611eae3f7381","os":"linux","os_features":"","os_version":null,"size":535502520,"status":"active","last_pulled":"2026-06-12T12:26:41.313393116Z","last_pushed":"2026-03-13T15:02:21.977810132Z"}],"last_updated":"2026-03-13T15:11:57.444216Z","last_updater":924487,"last_updater_username":"wezell","name":"26.03.12-01","repository":22062687,"full_size":535502520,"v2":true,"tag_status":"active","tag_last_pulled":"2026-06-12T12:26:41.313393116Z","tag_last_pushed":"2026-03-13T15:11:57.444216Z","media_type":"application/vnd.docker.container.image.v1+json","content_type":"image","digest":"sha256:3e94fbb14cba73fdd3ceec52de7b9b687f9f2e37d420f5827de9611eae3f7381"},{"creator":5352282,"id":520752463,"images":[{"architecture":"amd64","features":"","variant":null,"digest":"sha256:dba7eb94849e34d169c6f6e203e434f4098477f345b4fc7ac194638125604666","os":"linux","os_features":"","os_version":null,"size":420362600,"status":"active","last_pulled":"2026-06-12T17:55:13.300626135Z","last_pushed":"2023-09-28T14:29:56Z"}],"last_updated":"2023-09-28T14:29:55.909962Z","last_updater":5352282,"last_updater_username":"vicozizou","name":"26049-docker-build-and-publish","repository":22062687,"full_size":420362600,"v2":true,"tag_status":"active","tag_last_pulled":"2026-06-12T17:55:13.300626135Z","tag_last_pushed":"2023-09-28T14:29:55.909962Z","media_type":"application/vnd.docker.container.image.v1+json","content_type":"image","digest":"sha256:dba7eb94849e34d169c6f6e203e434f4098477f345b4fc7ac194638125604666"}]} \ No newline at end of file diff --git a/cicd/evergreen-tracks/tests/test_calver.py b/cicd/evergreen-tracks/tests/test_calver.py new file mode 100644 index 000000000000..5972c197d69f --- /dev/null +++ b/cicd/evergreen-tracks/tests/test_calver.py @@ -0,0 +1,29 @@ +import datetime as dt +import pytest +from evergreen_tracks.calver import Release, parse_release, age_days, newest + +def test_parse_ga_release(): + r = parse_release("26.06.11-01") + assert r == Release(version="26.06.11-01", date=dt.date(2026, 6, 11), build=1) + +def test_parse_rejects_non_ga(): + for tag in ["latest", "standard", "1.2.3-SNAPSHOT", + "25.07.10_lts_v12", "26.06.11-01_java25", "26.06.11-01_tainted"]: + assert parse_release(tag) is None + +def test_age_days(): + r = parse_release("26.06.01-01") + assert age_days(r, today=dt.date(2026, 6, 15)) == 14 + +def test_newest_breaks_tie_by_build(): + a = parse_release("26.06.11-01") + b = parse_release("26.06.11-02") + assert newest([a, b]) == b + +def test_newest_prefers_later_date(): + a = parse_release("26.06.11-09") + b = parse_release("26.06.12-01") + assert newest([a, b]) == b + +def test_newest_empty_is_none(): + assert newest([]) is None diff --git a/cicd/evergreen-tracks/tests/test_cli.py b/cicd/evergreen-tracks/tests/test_cli.py new file mode 100644 index 000000000000..9b85da320bb1 --- /dev/null +++ b/cicd/evergreen-tracks/tests/test_cli.py @@ -0,0 +1,546 @@ +"""Tests for the CLI layer (cli.py). + +All registry I/O is patched out via unittest.mock so tests run fully offline. +""" +from __future__ import annotations + +import datetime as dt +from unittest.mock import MagicMock, patch + +import pytest + +from evergreen_tracks.cli import ( + _current_version, + build_parser, + cmd_admin, + cmd_promote, + main, +) +from evergreen_tracks.registry import Tag + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_tag(name: str, digest: str = "sha256:abcdef1234567890") -> Tag: + return Tag(name=name, digest=digest) + + +def _tags_for_promote(): + """A minimal set of registry tags: two GA versions, no markers, no track tags.""" + return [ + _make_tag("26.06.02-01", "sha256:aaa"), + _make_tag("26.06.16-01", "sha256:bbb"), + ] + + +def _tags_for_hold(): + """Two GA versions where 26.06.02-01 already has a taint marker.""" + return [ + _make_tag("26.06.02-01", "sha256:aaa"), + _make_tag("26.06.16-01", "sha256:bbb"), + _make_tag("26.06.02-01_tainted", "sha256:aaa"), + ] + + +# --------------------------------------------------------------------------- +# build_parser: --apply lives on the subcommands, not the top-level parser +# --------------------------------------------------------------------------- + +def test_promote_apply_parses_correctly(): + """promote --apply must parse without error (blocking issue: --apply on subparser).""" + args = build_parser().parse_args(["promote", "--repo", "dotcms/dotcms-test", "--apply"]) + assert args.apply is True + + +def test_promote_apply_defaults_to_false(): + args = build_parser().parse_args(["promote", "--repo", "dotcms/dotcms-test"]) + assert args.apply is False + + +def test_admin_apply_parses_correctly(): + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", "--action", "taint", + "--version", "26.06.02-01", "--apply"] + ) + assert args.apply is True + + +def test_promote_apply_before_subcommand_is_unrecognized(): + """--apply before the subcommand is not valid (it lives on the subparser).""" + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["--apply", "promote", "--repo", "foo/bar"]) + assert exc_info.value.code == 2 + + +def test_promote_threshold_defaults(): + args = build_parser().parse_args(["promote", "--repo", "dotcms/dotcms-test"]) + assert args.latest_days == 0 + assert args.standard_days == 14 + assert args.trailing_days == 28 + + +def test_admin_force_default_false(): + args = build_parser().parse_args( + ["admin", "--repo", "r", "--action", "taint", "--version", "26.06.02-01"] + ) + assert args.force is False + + +# --------------------------------------------------------------------------- +# cmd_promote +# --------------------------------------------------------------------------- + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_dry_run_returns_zero(mock_list_tags, mock_point_tag): + """Dry-run promote must return 0 and call point_tag with apply=False (dry-run).""" + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["promote", "--repo", "dotcms/dotcms-test", + "--latest-days", "0", "--standard-days", "14", "--trailing-days", "28"] + ) + # Fix today so the test is deterministic: use a date where 26.06.16-01 is ≥14 days old. + with patch("evergreen_tracks.cli.dt") as mock_dt: + mock_dt.date.today.return_value = dt.date(2026, 7, 15) + rc = cmd_promote(args) + assert rc == 0 + # In dry-run (apply=False), point_tag is called but with apply=False (no actual mutation). + for call in mock_point_tag.call_args_list: + assert call.kwargs.get("apply") is False or call[1].get("apply") is False + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_no_moves_when_no_releases(mock_list_tags, mock_point_tag): + """When there are no GA releases, plan produces no moves and we return 0.""" + mock_list_tags.return_value = [_make_tag("latest", "sha256:aaa")] + args = build_parser().parse_args(["promote", "--repo", "dotcms/dotcms-test"]) + rc = cmd_promote(args) + assert rc == 0 + mock_point_tag.assert_not_called() + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_apply_calls_point_tag(mock_list_tags, mock_point_tag): + """With --apply and a pending move, point_tag must be called with apply=True.""" + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["promote", "--repo", "dotcms/dotcms-test", "--apply", + "--latest-days", "0", "--standard-days", "0", "--trailing-days", "0"] + ) + with patch("evergreen_tracks.cli.dt") as mock_dt: + mock_dt.date.today.return_value = dt.date(2026, 7, 15) + rc = cmd_promote(args) + assert rc == 0 + # point_tag should have been called for tracks that have a pending move + assert mock_point_tag.call_count >= 1 + # All calls must use apply=True + for call in mock_point_tag.call_args_list: + assert call.kwargs.get("apply") is True or call[1].get("apply") is True + + +# --------------------------------------------------------------------------- +# _current_version — newest GA wins on a shared digest (FIX 1) +# --------------------------------------------------------------------------- + +def test_current_version_picks_newest_on_shared_digest(): + """When two GA versions share the track tag's digest, the NEWEST must be returned.""" + from evergreen_tracks.calver import parse_release + + digests = { + "latest": "sha256:shared", + "26.06.02-01": "sha256:shared", # older GA, same digest + "26.06.16-01": "sha256:shared", # newer GA, same digest + } + releases = [ + parse_release("26.06.02-01"), + parse_release("26.06.16-01"), + ] + assert _current_version("latest", digests, releases) == "26.06.16-01" + + +def test_current_version_none_when_no_match(): + """No matching digest -> None.""" + from evergreen_tracks.calver import parse_release + + digests = {"latest": "sha256:zzz", "26.06.02-01": "sha256:aaa"} + releases = [parse_release("26.06.02-01")] + assert _current_version("latest", digests, releases) is None + + +# --------------------------------------------------------------------------- +# cmd_promote — held track reconciliation (FIX 2) +# --------------------------------------------------------------------------- + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_reconciles_held_track_to_hold_digest(mock_list_tags, mock_point_tag): + """A held track whose floating tag diverges from its hold marker must be reconciled + to the hold marker's digest (not promoted elsewhere).""" + mock_list_tags.return_value = [ + _make_tag("26.06.02-01", "sha256:aaa"), + _make_tag("26.06.16-01", "sha256:bbb"), + # standard is held to the older release... + _make_tag("standard_hold", "sha256:aaa"), + # ...but the floating standard tag has drifted to the newer digest. + _make_tag("standard", "sha256:bbb"), + ] + args = build_parser().parse_args( + ["promote", "--repo", "dotcms/dotcms-test", "--apply", + "--latest-days", "0", "--standard-days", "0", "--trailing-days", "0"] + ) + with patch("evergreen_tracks.cli.dt") as mock_dt: + mock_dt.date.today.return_value = dt.date(2026, 7, 15) + rc = cmd_promote(args) + assert rc == 0 + # The held "standard" track must be pointed at the hold marker digest (sha256:aaa), + # and never at the newer digest (sha256:bbb) as a promotion. + standard_calls = [c for c in mock_point_tag.call_args_list if c.args[1] == "standard"] + assert standard_calls, "expected a point_tag call reconciling the held 'standard' track" + for c in standard_calls: + assert c.args[2] == "sha256:aaa" + assert c.kwargs.get("apply") is True + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_held_track_logs_when_consistent(mock_list_tags, mock_point_tag, caplog): + """A held track already matching its hold marker must log that it is held and + skipped (no silent no-op), and emit no reconciling point_tag call.""" + mock_list_tags.return_value = [ + _make_tag("26.06.02-01", "sha256:aaa"), + _make_tag("standard_hold", "sha256:aaa"), + _make_tag("standard", "sha256:aaa"), # already consistent with the hold + ] + args = build_parser().parse_args( + ["promote", "--repo", "dotcms/dotcms-test", "--apply", + "--latest-days", "0", "--standard-days", "0", "--trailing-days", "0"] + ) + with caplog.at_level("INFO", logger="evergreen_tracks"): + with patch("evergreen_tracks.cli.dt") as mock_dt: + mock_dt.date.today.return_value = dt.date(2026, 7, 15) + rc = cmd_promote(args) + assert rc == 0 + assert any("held at standard_hold" in r.message for r in caplog.records) + assert not [c for c in mock_point_tag.call_args_list if c.args[1] == "standard"] + + +# --------------------------------------------------------------------------- +# cmd_admin — taint +# --------------------------------------------------------------------------- + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_taint_dry_run_returns_zero(mock_list_tags, mock_point_tag): + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "taint", "--version", "26.06.02-01"] + ) + rc = cmd_admin(args) + assert rc == 0 + mock_point_tag.assert_called_once() + _, kwargs = mock_point_tag.call_args + assert kwargs["apply"] is False + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_taint_version_not_in_registry_returns_2(mock_list_tags, mock_point_tag): + """Tainting a version that doesn't exist in the registry must return 2.""" + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "taint", "--version", "26.01.01-01"] + ) + rc = cmd_admin(args) + assert rc == 2 + mock_point_tag.assert_not_called() + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_taint_invalid_version_returns_2(mock_list_tags, mock_point_tag): + """Tainting a non-GA version string must return 2.""" + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "taint", "--version", "not-a-version"] + ) + rc = cmd_admin(args) + assert rc == 2 + mock_point_tag.assert_not_called() + + +# --------------------------------------------------------------------------- +# cmd_admin — untaint (requires DOCKER env vars) +# --------------------------------------------------------------------------- + +@patch("evergreen_tracks.cli.delete_tag") +@patch("evergreen_tracks.cli.hub_login", return_value="tok") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_untaint_with_env_vars_returns_zero(mock_list_tags, mock_login, mock_delete, monkeypatch): + monkeypatch.setenv("DOCKER_USERNAME", "user") + monkeypatch.setenv("DOCKER_TOKEN", "secret") + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "untaint", "--version", "26.06.02-01", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 0 + mock_login.assert_called_once_with("user", "secret") + mock_delete.assert_called_once() + + +@patch("evergreen_tracks.cli.list_tags") +def test_admin_untaint_missing_docker_username_returns_2(mock_list_tags, monkeypatch): + """Missing DOCKER_USERNAME must produce a clean error (not KeyError) and return 2.""" + monkeypatch.delenv("DOCKER_USERNAME", raising=False) + monkeypatch.setenv("DOCKER_TOKEN", "secret") + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "untaint", "--version", "26.06.02-01", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 2 + + +@patch("evergreen_tracks.cli.list_tags") +def test_admin_untaint_missing_docker_token_returns_2(mock_list_tags, monkeypatch): + """Missing DOCKER_TOKEN must produce a clean error (not KeyError) and return 2.""" + monkeypatch.setenv("DOCKER_USERNAME", "user") + monkeypatch.delenv("DOCKER_TOKEN", raising=False) + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "untaint", "--version", "26.06.02-01", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 2 + + +@patch("evergreen_tracks.cli.list_tags") +def test_admin_untaint_missing_both_env_vars_returns_2(mock_list_tags, monkeypatch): + """Missing both env vars must produce a clean error and return 2.""" + monkeypatch.delenv("DOCKER_USERNAME", raising=False) + monkeypatch.delenv("DOCKER_TOKEN", raising=False) + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "untaint", "--version", "26.06.02-01", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 2 + + +@patch("evergreen_tracks.cli.delete_tag") +@patch("evergreen_tracks.cli.hub_login") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_untaint_dry_run_needs_no_creds(mock_list_tags, mock_login, mock_delete, monkeypatch): + """Dry-run (no --apply) must not require creds or hit Hub login.""" + monkeypatch.delenv("DOCKER_USERNAME", raising=False) + monkeypatch.delenv("DOCKER_TOKEN", raising=False) + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "untaint", "--version", "26.06.02-01"] + ) + rc = cmd_admin(args) + assert rc == 0 + mock_login.assert_not_called() + mock_delete.assert_called_once() # called with apply=False -> logs the dry-run + + +# --------------------------------------------------------------------------- +# cmd_admin — hold +# --------------------------------------------------------------------------- + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_hold_unknown_track_returns_2(mock_list_tags, mock_point_tag): + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "hold", "--track", "bogus", "--version", "26.06.02-01"] + ) + rc = cmd_admin(args) + assert rc == 2 + mock_point_tag.assert_not_called() + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_hold_version_not_found_returns_2(mock_list_tags, mock_point_tag): + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "hold", "--track", "standard", "--version", "26.01.01-01"] + ) + rc = cmd_admin(args) + assert rc == 2 + mock_point_tag.assert_not_called() + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_hold_tainted_version_without_force_returns_2(mock_list_tags, mock_point_tag): + """Holding a tainted version without --force must be rejected (return 2).""" + mock_list_tags.return_value = _tags_for_hold() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "hold", "--track", "standard", "--version", "26.06.02-01"] + ) + rc = cmd_admin(args) + assert rc == 2 + mock_point_tag.assert_not_called() + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_hold_tainted_version_with_force_returns_zero(mock_list_tags, mock_point_tag): + """Holding a tainted version with --force is allowed.""" + mock_list_tags.return_value = _tags_for_hold() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "hold", "--track", "standard", "--version", "26.06.02-01", "--force"] + ) + rc = cmd_admin(args) + assert rc == 0 + assert mock_point_tag.call_count == 2 # marker + track tag + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_hold_clean_version_returns_zero(mock_list_tags, mock_point_tag): + """Normal hold on a clean, existing version must call point_tag twice and return 0.""" + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "hold", "--track", "standard", "--version", "26.06.02-01"] + ) + rc = cmd_admin(args) + assert rc == 0 + assert mock_point_tag.call_count == 2 # hold marker + track tag itself + + +# --------------------------------------------------------------------------- +# cmd_admin — release-hold (requires DOCKER env vars) +# --------------------------------------------------------------------------- + +@patch("evergreen_tracks.cli.delete_tag") +@patch("evergreen_tracks.cli.hub_login", return_value="tok") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_release_hold_with_env_vars_returns_zero(mock_list_tags, mock_login, mock_delete, monkeypatch): + monkeypatch.setenv("DOCKER_USERNAME", "user") + monkeypatch.setenv("DOCKER_TOKEN", "secret") + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "release-hold", "--track", "standard", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 0 + mock_login.assert_called_once_with("user", "secret") + mock_delete.assert_called_once() + + +@patch("evergreen_tracks.cli.list_tags") +def test_admin_release_hold_missing_docker_token_returns_2(mock_list_tags, monkeypatch): + """Missing DOCKER_TOKEN for release-hold must return 2, not crash with KeyError.""" + monkeypatch.setenv("DOCKER_USERNAME", "user") + monkeypatch.delenv("DOCKER_TOKEN", raising=False) + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "release-hold", "--track", "standard", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 2 + + +@patch("evergreen_tracks.cli.list_tags") +def test_admin_release_hold_missing_docker_username_returns_2(mock_list_tags, monkeypatch): + monkeypatch.delenv("DOCKER_USERNAME", raising=False) + monkeypatch.setenv("DOCKER_TOKEN", "secret") + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "release-hold", "--track", "standard", "--apply"] + ) + rc = cmd_admin(args) + assert rc == 2 + + +@patch("evergreen_tracks.cli.delete_tag") +@patch("evergreen_tracks.cli.hub_login") +@patch("evergreen_tracks.cli.list_tags") +def test_admin_release_hold_dry_run_needs_no_creds(mock_list_tags, mock_login, mock_delete, monkeypatch): + """release-hold dry-run (no --apply) must not require creds or hit Hub login.""" + monkeypatch.delenv("DOCKER_USERNAME", raising=False) + monkeypatch.delenv("DOCKER_TOKEN", raising=False) + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "release-hold", "--track", "standard"] + ) + rc = cmd_admin(args) + assert rc == 0 + mock_login.assert_not_called() + mock_delete.assert_called_once() + + +@patch("evergreen_tracks.cli.list_tags") +def test_admin_release_hold_unknown_track_returns_2(mock_list_tags, monkeypatch): + monkeypatch.setenv("DOCKER_USERNAME", "user") + monkeypatch.setenv("DOCKER_TOKEN", "secret") + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["admin", "--repo", "dotcms/dotcms-test", + "--action", "release-hold", "--track", "bogus"] + ) + rc = cmd_admin(args) + assert rc == 2 + + +# --------------------------------------------------------------------------- +# cmd_promote — --tracks subset (release pipeline moves only `latest`) +# --------------------------------------------------------------------------- + +def test_promote_tracks_defaults_empty(): + args = build_parser().parse_args(["promote", "--repo", "dotcms/dotcms-test"]) + assert args.tracks == "" + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_tracks_latest_moves_only_latest(mock_list_tags, mock_point_tag): + """--tracks latest must move ONLY the latest tag, even when standard/trailing + are also eligible (thresholds 0). This is the release-pipeline invocation.""" + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["promote", "--repo", "dotcms/dotcms-test", "--apply", "--tracks", "latest", + "--latest-days", "0", "--standard-days", "0", "--trailing-days", "0"] + ) + with patch("evergreen_tracks.cli.dt") as mock_dt: + mock_dt.date.today.return_value = dt.date(2026, 7, 15) + rc = cmd_promote(args) + assert rc == 0 + moved = {call.args[1] for call in mock_point_tag.call_args_list} + assert moved == {"latest"} + + +@patch("evergreen_tracks.cli.point_tag") +@patch("evergreen_tracks.cli.list_tags") +def test_promote_tracks_unknown_returns_2(mock_list_tags, mock_point_tag): + mock_list_tags.return_value = _tags_for_promote() + args = build_parser().parse_args( + ["promote", "--repo", "dotcms/dotcms-test", "--tracks", "bogus"] + ) + rc = cmd_promote(args) + assert rc == 2 + mock_point_tag.assert_not_called() diff --git a/cicd/evergreen-tracks/tests/test_executor.py b/cicd/evergreen-tracks/tests/test_executor.py new file mode 100644 index 000000000000..22a482d72f45 --- /dev/null +++ b/cicd/evergreen-tracks/tests/test_executor.py @@ -0,0 +1,129 @@ +"""Tests for the executor write layer. + +Uses `responses` to intercept HTTP calls and `unittest.mock.patch` to intercept +subprocess calls, keeping the tests fully offline. +""" +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +import responses as responses_lib + +from evergreen_tracks.executor import delete_tag, hub_login, point_tag + + +# --------------------------------------------------------------------------- +# point_tag +# --------------------------------------------------------------------------- + +def test_point_tag_dry_run_does_not_call_subprocess(): + with patch("subprocess.run") as mock_run: + point_tag("dotcms/dotcms-test", "latest", "sha256:abc123", apply=False) + mock_run.assert_not_called() + + +def test_point_tag_apply_calls_imagetools_create(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + point_tag("dotcms/dotcms-test", "latest", "sha256:abc123", apply=True) + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "docker" in cmd + assert "imagetools" in cmd + assert "create" in cmd + assert "dotcms/dotcms-test:latest" in cmd + assert "dotcms/dotcms-test@sha256:abc123" in cmd + + +def test_point_tag_apply_propagates_subprocess_error(): + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "docker")): + with pytest.raises(subprocess.CalledProcessError): + point_tag("dotcms/dotcms-test", "latest", "sha256:abc123", apply=True) + + +# --------------------------------------------------------------------------- +# hub_login +# --------------------------------------------------------------------------- + +@responses_lib.activate +def test_hub_login_returns_token_on_success(): + responses_lib.add( + responses_lib.POST, + "https://hub.docker.com/v2/users/login", + json={"token": "mytoken123"}, + status=200, + ) + token = hub_login("myuser", "mypassword") + assert token == "mytoken123" + + +@responses_lib.activate +def test_hub_login_raises_descriptive_error_when_token_missing(): + """If the Hub returns 200 with no 'token' key, hub_login should raise RuntimeError.""" + responses_lib.add( + responses_lib.POST, + "https://hub.docker.com/v2/users/login", + json={"detail": "incorrect_authentication_credentials"}, + status=200, + ) + with pytest.raises(RuntimeError, match="token"): + hub_login("baduser", "badpassword") + + +@responses_lib.activate +def test_hub_login_raises_on_http_error(): + responses_lib.add( + responses_lib.POST, + "https://hub.docker.com/v2/users/login", + json={"detail": "unauthorized"}, + status=401, + ) + with pytest.raises(Exception): + hub_login("myuser", "wrongpassword") + + +# --------------------------------------------------------------------------- +# delete_tag +# --------------------------------------------------------------------------- + +@responses_lib.activate +def test_delete_tag_dry_run_makes_no_http_call(): + # No responses registered — if an HTTP call is made, responses will raise ConnectionError. + delete_tag("dotcms/dotcms-test", "26.06.11-01_tainted", "mytoken", apply=False) + # If we get here without an error, no HTTP call was made. + + +@responses_lib.activate +def test_delete_tag_apply_sends_delete_request(): + responses_lib.add( + responses_lib.DELETE, + "https://hub.docker.com/v2/repositories/dotcms/dotcms-test/tags/26.06.11-01_tainted/", + status=204, + ) + delete_tag("dotcms/dotcms-test", "26.06.11-01_tainted", "mytoken", apply=True) + assert len(responses_lib.calls) == 1 + assert responses_lib.calls[0].request.headers["Authorization"] == "JWT mytoken" + + +@responses_lib.activate +def test_delete_tag_apply_treats_404_as_success(): + responses_lib.add( + responses_lib.DELETE, + "https://hub.docker.com/v2/repositories/dotcms/dotcms-test/tags/26.06.11-01_tainted/", + status=404, + ) + # Should NOT raise even though 404 + delete_tag("dotcms/dotcms-test", "26.06.11-01_tainted", "mytoken", apply=True) + + +@responses_lib.activate +def test_delete_tag_apply_raises_on_server_error(): + responses_lib.add( + responses_lib.DELETE, + "https://hub.docker.com/v2/repositories/dotcms/dotcms-test/tags/26.06.11-01_tainted/", + status=500, + ) + with pytest.raises(Exception): + delete_tag("dotcms/dotcms-test", "26.06.11-01_tainted", "mytoken", apply=True) diff --git a/cicd/evergreen-tracks/tests/test_markers.py b/cicd/evergreen-tracks/tests/test_markers.py new file mode 100644 index 000000000000..39a5aaaf4008 --- /dev/null +++ b/cicd/evergreen-tracks/tests/test_markers.py @@ -0,0 +1,21 @@ +from evergreen_tracks.markers import ( + TRACKS, taint_tag, hold_tag, + tainted_versions, held_tracks, +) + +def test_tag_name_helpers(): + assert taint_tag("26.06.11-01") == "26.06.11-01_tainted" + assert hold_tag("standard") == "standard_hold" + +def test_tainted_versions_extracted(): + tags = ["26.06.11-01", "26.06.11-01_tainted", "26.05.01-01", "latest"] + assert tainted_versions(tags) == {"26.06.11-01"} + +def test_held_tracks_extracted(): + tags = ["standard", "standard_hold", "trailing", "latest_hold"] + assert held_tracks(tags) == {"standard", "latest"} + +def test_held_tracks_ignores_unknown_hold(): + # a _hold suffix on a non-track name is ignored + tags = ["bogus_hold", "standard_hold"] + assert held_tracks(tags) == {"standard"} diff --git a/cicd/evergreen-tracks/tests/test_planner.py b/cicd/evergreen-tracks/tests/test_planner.py new file mode 100644 index 000000000000..7c85fb97d4eb --- /dev/null +++ b/cicd/evergreen-tracks/tests/test_planner.py @@ -0,0 +1,66 @@ +import datetime as dt +from evergreen_tracks.calver import parse_release +from evergreen_tracks.planner import TrackState, Move, plan + +TODAY = dt.date(2026, 6, 30) + +def releases(*tags): + return [parse_release(t) for t in tags] + +REL = releases( + "26.06.29-01", # 1 day old + "26.06.16-01", # 14 days old + "26.06.02-01", # 28 days old + "26.05.20-01", # 41 days old +) + +def track(name, days, current): + return TrackState(name=name, threshold_days=days, current_version=current) + +def test_standard_picks_newest_at_least_14_days_old(): + moves = plan(REL, tainted=set(), held=set(), + tracks=[track("standard", 14, current="26.06.02-01")], today=TODAY) + assert moves == [Move(track="standard", target_version="26.06.16-01")] + +def test_trailing_picks_newest_at_least_28_days_old(): + moves = plan(REL, tainted=set(), held=set(), + tracks=[track("trailing", 28, current="26.05.20-01")], today=TODAY) + assert moves == [Move(track="trailing", target_version="26.06.02-01")] + +def test_no_move_when_already_on_target(): + moves = plan(REL, tainted=set(), held=set(), + tracks=[track("standard", 14, current="26.06.16-01")], today=TODAY) + assert moves == [] + +def test_seed_when_track_missing(): + moves = plan(REL, tainted=set(), held=set(), + tracks=[track("standard", 14, current=None)], today=TODAY) + assert moves == [Move(track="standard", target_version="26.06.16-01")] + +def test_monotonic_never_moves_backward(): + # current is already newer than the newest 28-day-eligible release + moves = plan(REL, tainted=set(), held=set(), + tracks=[track("trailing", 28, current="26.06.16-01")], today=TODAY) + assert moves == [] + +def test_taint_skips_to_next_eligible(): + # 26.06.16-01 would be standard's target, but it's tainted -> next older eligible + moves = plan(REL, tainted={"26.06.16-01"}, held=set(), + tracks=[track("standard", 14, current="26.05.20-01")], today=TODAY) + assert moves == [Move(track="standard", target_version="26.06.02-01")] + +def test_track_on_tainted_release_stays_when_no_newer_eligible(): + # current is tainted, nothing newer is eligible+clean -> stay put (no move, no rollback) + moves = plan(releases("26.06.16-01"), tainted={"26.06.16-01"}, held=set(), + tracks=[track("standard", 14, current="26.06.16-01")], today=TODAY) + assert moves == [] + +def test_held_track_emits_no_move(): + moves = plan(REL, tainted=set(), held={"standard"}, + tracks=[track("standard", 14, current="26.06.02-01")], today=TODAY) + assert moves == [] + +def test_latest_threshold_zero_picks_freshest(): + moves = plan(REL, tainted=set(), held=set(), + tracks=[track("latest", 0, current="26.06.16-01")], today=TODAY) + assert moves == [Move(track="latest", target_version="26.06.29-01")] diff --git a/cicd/evergreen-tracks/tests/test_registry.py b/cicd/evergreen-tracks/tests/test_registry.py new file mode 100644 index 000000000000..dc43b0c05312 --- /dev/null +++ b/cicd/evergreen-tracks/tests/test_registry.py @@ -0,0 +1,41 @@ +import json +import pathlib +import responses +from evergreen_tracks.registry import list_tags + +FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "hub_tags.json" + +@responses.activate +def test_list_tags_paginates_and_returns_name_digest(): + """Two-page pagination: first response has 'next' pointing to page 2, second has next=null.""" + fixture_data = json.loads(FIXTURE.read_text()) + # Page 1: single result with next pointing to page 2 + page1 = { + "count": 2, + "next": "https://hub.docker.com/v2/namespaces/dotcms/repositories/dotcms-test/tags?page=2&page_size=100", + "previous": None, + "results": [fixture_data["results"][0]], # 26.03.12-01 + } + # Page 2: single result with next=null + page2 = { + "count": 2, + "next": None, + "previous": "https://hub.docker.com/v2/namespaces/dotcms/repositories/dotcms-test/tags?page=1&page_size=100", + "results": [fixture_data["results"][1]], # 26049-docker-build-and-publish + } + responses.add( + responses.GET, + "https://hub.docker.com/v2/namespaces/dotcms/repositories/dotcms-test/tags", + json=page1, status=200, + ) + responses.add( + responses.GET, + "https://hub.docker.com/v2/namespaces/dotcms/repositories/dotcms-test/tags", + json=page2, status=200, + ) + tags = list_tags("dotcms/dotcms-test") + tag_names = {t.name for t in tags} + # Tags from both pages must appear + assert "26.03.12-01" in tag_names + assert "26049-docker-build-and-publish" in tag_names + assert all(isinstance(t.name, str) and t.digest.startswith("sha256:") for t in tags) diff --git a/cicd/evergreen-tracks/uv.lock b/cicd/evergreen-tracks/uv.lock new file mode 100644 index 000000000000..d71ac166fd4f --- /dev/null +++ b/cicd/evergreen-tracks/uv.lock @@ -0,0 +1,262 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "evergreen-tracks" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "responses" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = ">=2.32" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "responses", specifier = ">=0.25" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "responses" +version = "0.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]