diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6495adffc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Line endings are part of a file's bytes, and Condor digests some of those +# bytes: `content_digest` in condor/layering.py stamps a forked agent file with +# a hash of the stock file it came from, and the staleness check compares that +# stamp against the stock file later. Leaving endings to each host's git config +# makes that hash platform-dependent. +# +# `text=auto` normalizes to LF in the repository. The explicit `eol=lf` entries +# are the files whose bytes are either hashed or executed, where a CRLF working +# tree would be actively wrong rather than merely inconsistent. +* text=auto + +# Agent content: hashed by content_digest, so the working tree must match what +# was committed byte for byte on every platform. +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Executed by a POSIX shell; a CRLF copy fails with a bad-interpreter error. +*.sh text eol=lf +Makefile text eol=lf + +# Binary, so git must not touch them. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore index 771026a86..559115945 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,10 @@ frontend/.claude/ # one's commit — which is how 1529d04 came to carry a message describing work it does not # contain. Kept on disk, out of the index. GATEWAY_ISSUES.md + +# The dashboard is built into dist.new and swapped into place, keeping dist.old +# as a rollback (see build_frontend). `dist/` above does not match either name, +# and an unignored scratch directory would make every mid-build tree dirty -- +# which is itself something repo_blocks() can refuse an update over. +frontend/dist.new/ +frontend/dist.old/ diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile index fb00b8a17..f5b3eeab0 100644 --- a/Makefile +++ b/Makefile @@ -53,10 +53,14 @@ install: setup @$(MAKE) setup-chrome @-$(MAKE) doctor +# Bounded, and says which way it failed. The bare `python -c` this replaces had +# no timeout and sent stderr to /dev/null, so a stalled download sat on +# "Setting up Chrome..." for ever with nothing to read and no way to finish the +# install. See condor/setup_chrome.py; it always exits 0, because charts are +# optional and an optional renderer must not fail an install. setup-chrome: @echo "Setting up Chrome for chart rendering..." - @uv run python -c "import kaleido; kaleido.get_chrome_sync()" 2>/dev/null || \ - echo "Chrome setup skipped (not required for basic usage)" + @uv run python -m condor.setup_chrome || true doctor: @bash -c ' \ @@ -65,6 +69,13 @@ doctor: uv run python -m condor.doctor \ ' +# Builds into dist.new and swaps, exactly as the in-process updater does +# (utils/updater.build_frontend). vite's `emptyOutDir` would otherwise empty the +# directory in place: `run` and `restart` stop Condor first, but this target is +# public and a Condor started any other way -- `run-fg`, a supervisor, a second +# checkout -- is left serving an empty dist for the length of the build, and a +# build that then fails leaves it with no dashboard at all. +# # Reinstall when the lockfile moved, not merely when node_modules is absent: # after the first boot the directory always exists, so a pull that adds a # dependency would otherwise build against a stale tree and fail. npm rewrites @@ -79,7 +90,11 @@ build-frontend: [ package-lock.json -nt node_modules/.package-lock.json ]; then \ npm ci || exit 1; \ fi; \ - npm run build \ + rm -rf dist.new && \ + npm run build -- --outDir dist.new --emptyOutDir && \ + rm -rf dist.old && \ + { [ -d dist ] && mv dist dist.old || true; } && \ + mv dist.new dist \ ' # Fails early (before the frontend build) if Condor is already up diff --git a/condor/doctor.py b/condor/doctor.py index 206d85265..bada1abe0 100644 --- a/condor/doctor.py +++ b/condor/doctor.py @@ -176,6 +176,81 @@ def check_dependencies() -> list[Check]: _PLACEHOLDER_CREDENTIALS = {"admin", "password", "changeme", ""} +def _check_admin_can_administer(data: dict | None) -> list[Check]: + """Whether ``ADMIN_USER_ID`` is an id ``config.yml`` treats as an admin. + + Two files have to agree about one number and nothing checked that they + did. ``.env`` decides who the dashboard *is* -- local mode logs in as + ``ADMIN_USER_ID`` with no password -- while ``config.yml`` decides what + that id may *do*. Disagree and every admin route answers 403: the Updates + tab is not rendered, the "Updates available" notification links to a tab + that is not there, and clicking it lands on Servers with nothing said. + + Nothing was wrong enough to notice. The old check asked only whether the + variable was set, so an install in exactly this state reported + ``ADMIN_USER_ID`` green on the very value that had no rights. + + A duplicated key is worth naming on its own: ``.env`` is read by three + parsers and the last assignment wins in all of them, so a second line is + silently authoritative and the first is the one people read. + """ + from utils.config import ADMIN_USER_ID, LOCAL_MODE + + admin_id = str(ADMIN_USER_ID or "").strip() + if not admin_id or not isinstance(data, dict): + return [] + + duplicates: list[Check] = [] + if ENV_PATH.exists(): + try: + assignments = [ + line + for line in ENV_PATH.read_text(encoding="utf-8").splitlines() + if re.match(r"^\s*(export\s+)?ADMIN_USER_ID\s*=", line) + ] + except OSError: + assignments = [] + if len(assignments) > 1: + duplicates.append( + Check( + "ADMIN_USER_ID (duplicate)", + FAIL, + f"{len(assignments)} assignments in .env — the last one wins " + f"({admin_id}), the others are dead. Delete all but one.", + ) + ) + + users = data.get("users") or {} + role = "" + for key, record in users.items(): + if str(key) == admin_id and isinstance(record, dict): + role = str(record.get("role") or "") + break + + if role.lower() == "admin": + return duplicates + + if not role: + detail = ( + f"config.yml has no user {admin_id}, so the dashboard logs in as an " + "id with no role at all and every admin route answers 403." + ) + else: + detail = ( + f"config.yml gives user {admin_id} the role '{role}', not 'admin', " + "so every admin route answers 403 — no Updates tab, and the " + "'Updates available' notice links to a tab that is not rendered." + ) + where = "the dashboard signs in as this id" if LOCAL_MODE else "this id" + return duplicates + [ + Check( + "Admin access", + FAIL, + f"{detail} Set `users.{admin_id}.role: admin` in config.yml " f"({where}).", + ) + ] + + def _check_placeholder_credentials(data: object) -> list[Check]: if not isinstance(data, dict): return [] @@ -262,6 +337,7 @@ def check_config() -> list[Check]: data = yaml.safe_load(config_yml.read_text(encoding="utf-8")) checks.append(Check("config.yml", OK, "present and parses")) + checks.extend(_check_admin_can_administer(data)) checks.extend(_check_placeholder_credentials(data)) except Exception as e: checks.append(Check("config.yml", FAIL, f"failed to parse: {e}")) @@ -317,8 +393,17 @@ def _listening_binds(port: int) -> list[str]: binds = [] for line in proc.stdout.splitlines()[1:]: # skip header row cols = line.split() + # ``-sTCP:LISTEN`` makes lsof print the state as its own + # trailing token, so NAME is ``TCP 127.0.0.1:8088 (LISTEN)`` + # and the last column is ``(LISTEN)`` -- not an address at all. + # Taking it and splitting on "(" yielded the empty string, + # which ``_is_public_bind`` used to read as a wildcard: every + # loopback listener on a host without ``ss`` reported as bound + # to all interfaces. + if cols and cols[-1].startswith("("): + cols = cols[:-1] if cols: - binds.append(cols[-1].split("(")[0]) + binds.append(cols[-1]) return binds except Exception: pass @@ -357,8 +442,15 @@ def _bind_host(addr: str) -> str: def _is_public_bind(addr: str) -> bool: + """Whether ``addr`` is a wildcard bind — reachable from off this machine. + + The empty string is deliberately *not* a wildcard. It is what a bind this + code failed to parse looks like, and answering True for it turns a parsing + bug into a security warning about a port that may well be on loopback. + Unknown is not public; a bind nobody could read is reported by nothing. + """ host = _bind_host(addr) - return host in ("0.0.0.0", "*", "::", "[::]", "") + return host in ("0.0.0.0", "*", "::", "[::]") def check_dashboard_port() -> list[Check]: diff --git a/condor/layering.py b/condor/layering.py index 24e54b291..d989ce912 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -37,8 +37,9 @@ import hashlib import logging import shutil +from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from condor.frontmatter import parse_frontmatter, render_frontmatter from condor.fsutil import atomic_write_text @@ -50,13 +51,23 @@ def content_digest(path: Path) -> str: - """``sha256:<12>`` of a file's bytes — short enough to read in a log line. + """``sha256:<12>`` of a file's content, with line endings normalized. Twelve hex characters is the same order of collision resistance a git short hash gives, against a corpus of a few hundred markdown files. It identifies *which* upstream revision was forked; it is not a security claim. + + Newlines are normalized to LF before hashing, because the stamp has to mean + the same thing on every platform. Hashing raw bytes made the digest depend + on the checkout's line endings — the same file gave sha256:6d9b716cc24c on + an LF checkout and sha256:ee60f3851972 on a CRLF one — so a ``forked_from`` + written on one host compared as *changed* on another, and every fork looked + stale for a reason that had nothing to do with upstream. ``.gitattributes`` + now keeps the tracked tree LF; this makes the comparison correct even where + it does not, such as a local root carried between machines. """ - digest = hashlib.sha256(path.read_bytes()).hexdigest() + raw = path.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") + digest = hashlib.sha256(raw).hexdigest() return f"sha256:{digest[:12]}" @@ -225,6 +236,273 @@ def carry_fork_stamp(path: Path, meta: dict) -> dict: return meta +@dataclass(frozen=True) +class StaleFork: + """A forked item whose stock counterpart has moved, or gone, since the fork.""" + + path: Path + """The local file that is being used instead of stock.""" + rel: str + """Its path relative to the local root — what a message should show.""" + forked_from: str + """The digest of the stock file at fork time; ``""`` when never stamped.""" + stock_digest: str | None + """The stock file's digest now; ``None`` when it no longer exists.""" + owner: str = "" + """Which library it belongs to -- an agent slug, or ``_shared``.""" + + @property + def label(self) -> str: + """What a message should show: the item qualified by its library. + + :attr:`rel` is relative to *one* library's root, so three agents with a + customized playbook each render as a bare ``AGENT.md`` and the reader + cannot tell which is which. Nine agents ship, so that is the ordinary + case rather than an edge one. + """ + return f"{self.owner}/{self.rel}" if self.owner else self.rel + + @property + def retired(self) -> bool: + """Whether upstream removed the stock counterpart entirely.""" + return self.stock_digest is None + + @property + def unprovenanced(self) -> bool: + """Shadowing stock with no record of ever having been the same file. + + Two ways to get here, and they need the same sentence. A file with no + frontmatter cannot hold a stamp -- a routine's ``.py`` is the case that + matters, since the library ships nineteen of them -- so a forked skill + folder stamps its ``SKILL.md`` and nothing else. And a file the agent + *created* under a name upstream later ships collides the same way: local + wins per item, so upstream's version never appears. + """ + return not self.forked_from + + +def _legacy_digest(path: Path) -> str: + """What :func:`content_digest` returned before newlines were normalized. + + A stamp written from a CRLF working tree holds the raw-bytes hash, and after + normalization it would compare as changed for a reason that has nothing to + do with upstream. Checking the legacy form as well keeps those stamps + meaningful instead of reporting every one of them stale once. + """ + return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()[:12]}" + + +def stale_forks(agent_slug: str | None) -> list[StaleFork]: + """Forked items of one agent whose stock counterpart has since moved. + + The other half of copy-on-write, and the half that was missing. + :func:`fork_path` records ``forked_from`` and nothing ever read it back, so + a customized playbook kept shadowing stock for ever: the update succeeded, + upstream's rewrite landed on disk, and the agent never saw it. Upstream + churns exactly these files, and they carry trading rules and risk limits, so + the divergence is both silent and consequential. + + This only *reports*. Local still wins — that is the deliberate promise of + FEAT-115, and an update that quietly took upstream's version back would be + the old loud failure wearing a new coat. What the operator gets is the + sentence nobody was being told. + + Items with no stamp are skipped: a file the operator authored from scratch + was never a fork of anything, so there is nothing for it to be stale + against. + """ + local, stock = _homes(agent_slug) + # Read the owner off the resolved path rather than from ``agent_slug``, + # which is None for the chat agent and would leave its items unqualified. + return _compare_layer(local, stock, local.name) + + +def _compare_layer( + local: Path, stock: Path, owner: str, rel_prefix: str = "" +) -> list[StaleFork]: + """Every file under ``local`` measured against ``stock/``. + + Split out of :func:`stale_forks` because one layer does not resolve that + way: the chat agent's routines fall back to the repo-root ``routines/`` + rather than to ``/condor/routines`` (see :func:`_chat_routine_forks`), + so it needs the same comparison against a stock root of its own. + + ``rel_prefix`` keeps the reported path the one the reader would look for -- + a chat routine is reported as ``routines/.py``, which is where it sits + under the agent, not as the bare filename the scan happens to walk. + """ + if not local.is_dir(): + return [] + + out: list[StaleFork] = [] + for path in sorted(local.rglob("*")): + if not path.is_file(): + continue + + rel = path.relative_to(local) + counterpart = stock / rel + shown = f"{rel_prefix}/{rel}" if rel_prefix else str(rel) + forked_from = _stamp_of(path) + + if not counterpart.is_file(): + # No stock counterpart. Either upstream retired something this + # install forked -- worth saying, since reads never reconcile the + # roots and a withdrawn playbook keeps running -- or it is an + # ordinary file the agent authored, which was never a fork of + # anything and has nothing to be stale against. + if forked_from: + out.append(StaleFork(path, shown, forked_from, None, owner)) + continue + + now = content_digest(counterpart) + if forked_from: + if now == forked_from or _legacy_digest(counterpart) == forked_from: + continue + out.append(StaleFork(path, shown, forked_from, now, owner)) + continue + + # No stamp, but stock ships this exact path, so the local copy is + # shadowing it. Identical content means the fork simply never diverged + # -- nothing to report. Different content means upstream's version is + # unreachable and nothing recorded that it ever matched. + if now != content_digest(path): + out.append(StaleFork(path, shown, "", now, owner)) + return out + + +def _stamp_of(path: Path) -> str: + """The ``forked_from`` digest recorded on ``path``, or ``""``. + + Only markdown can carry one: :func:`_stamp` deliberately refuses to invent + frontmatter for a file that has none, because that would change what the + file *is* and the machinery reading a routine's ``.py`` would not survive it. + """ + if path.suffix != ".md": + return "" + try: + meta, _ = parse_frontmatter(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + return "" + return str((meta or {}).get(FORKED_FROM_KEY) or "") + + +def locally_overridden(rel_paths: list[str]) -> list[str]: + """Which of ``rel_paths`` this install already has its own version of. + + ``rel_paths`` are repo-relative, as ``incoming_paths`` reports them, so only + those under ``agents/`` can have a local counterpart at all. + + This is the *predictive* half of the staleness story. :func:`stale_forks` + compares a fork against the stock file on disk, and that only moves once the + fast-forward has landed -- so it answers "upstream has already moved on" + after the fact. This answers "the update you are about to apply will change + files you have overridden" while there is still a decision to make. + """ + from condor.paths import local_agents_root + + root = local_agents_root() + if not root.is_dir(): + return [] + + out: list[str] = [] + for rel in rel_paths: + parts = PurePosixPath(rel).parts + if len(parts) < 2: + continue + if parts[0] == "agents": + local = root.joinpath(*parts[1:]) + elif parts[0] == "routines": + # The chat agent's library is the exception: it is shipped at the + # repo root, not under ``agents/``, and its local layer sits at + # ``/condor/routines`` (see ``_chat_routine_forks``). Without + # this arm an update that rewrites a shipped routine the chat has + # improved matched nothing, and the one warning that fires before + # the change lands never mentioned it. + local = root.joinpath(CHAT_LIBRARY, *parts) + else: + continue + if local.is_file(): + out.append(rel) + return out + + +#: The library every agent reads *under* its own — published skills, routines +#: and controllers. Not an agent, so ``iter_agent_slugs`` never yields it: +#: ``_is_agent_dir`` rejects a leading underscore, which is what kept three +#: whole trees out of the scan below. +SHARED_LIBRARY = "_shared" + +#: The chat agent's own library. Named here because its routines are the one +#: layer that falls back to the repo root rather than to the shipped agent tree. +CHAT_LIBRARY = "condor" + + +def _chat_routine_forks() -> list[StaleFork]: + """Chat-authored routines shadowing the library they were copied from. + + The one layer whose stock root is not ``//``. FEAT-033 keeps + the shipped library at the repo-root ``routines/`` and FEAT-115 moved only + the *write* target under the agent, so the pair is + ``(/condor/routines, /routines)`` -- which is what + :func:`routines.base.assistant_routines_dirs` resolves and what the loader + actually reads. + + :func:`stale_forks` cannot see it. It asks ``_homes`` for the pair, gets + ``/condor/routines`` as the stock side, finds nothing there on any + install, and files every chat routine under "an ordinary file the agent + authored" -- so improving a shipped routine, which is the whole point of + the local layer, shadowed it in silence. Ask the loader where stock is + instead. + """ + from routines.base import assistant_routines_dirs + + local, stock = assistant_routines_dirs(None) + return _compare_layer(local, stock, CHAT_LIBRARY, rel_prefix="routines") + + +def all_stale_forks() -> list[StaleFork]: + """:func:`stale_forks` across every agent this install has, plus the shared root.""" + from condor.memory.paths import iter_agent_slugs + + out: list[StaleFork] = [] + seen: set[Path] = set() + # The chat routines first: the per-agent scan below walks the same files + # under ``/condor`` and would judge them against the wrong root. + for fork in [*_chat_routine_forks()]: + seen.add(fork.path) + out.append(fork) + for slug in [None, SHARED_LIBRARY, *iter_agent_slugs()]: + for fork in stale_forks(slug): + if fork.path in seen: + continue + seen.add(fork.path) + out.append(fork) + return out + + +def write_preserving_stamp(path: Path, content: str) -> None: + """Write ``content`` to ``path`` without destroying its fork stamp. + + The three *stores* already do this by hand, rendering their frontmatter + through :func:`carry_fork_stamp`. The web routes did not: they took a body + the browser had round-tripped and wrote it straight down, and because the + GET that produced it had read the **stock** file — which carries no stamp — + the fork's stamp was written by :func:`fork_path` and erased by the very + next line. Editing an agent through the dashboard is the primary way anyone + edits these files, so in practice the stamp almost never survived. + + Factored out rather than inlined at the two call sites so a third write path + cannot reintroduce the same bug by forgetting the dance. + """ + if path.suffix != ".md": + atomic_write_text(path, content) + return + meta, body = parse_frontmatter(content) + atomic_write_text( + path, render_frontmatter(carry_fork_stamp(path, dict(meta or {})), body) + ) + + def clear_fork_stamp(path: Path) -> None: """Drop ``forked_from``/``forked_at`` — used when publishing back into stock. diff --git a/condor/migrations.py b/condor/migrations.py index 652fb469d..37985be4a 100644 --- a/condor/migrations.py +++ b/condor/migrations.py @@ -132,6 +132,7 @@ MARKER_V3_FILENAME = ".migrated-v3" MARKER_V4_FILENAME = ".migrated-v4" MARKER_V5_FILENAME = ".migrated-v5" +MARKER_V6_FILENAME = ".migrated-v6" BACKUPS_DIRNAME = "migration-backups" # Runtime output that lived under a *tracked* agent directory. Everything here @@ -176,6 +177,8 @@ class MigrationReport: loops_renamed: int = 0 loop_md_renamed: int = 0 mutes_rewritten: int = 0 + # v6: chat-authored routines lifted out of the tracked library + chat_routines: int = 0 @property def total(self) -> int: @@ -195,6 +198,60 @@ def total(self) -> int: ) +def _lift_chat_routines(report: MigrationReport) -> None: + """Move chat-authored routines out of the tracked library into the local root. + + The chat agent's writable routines dir used to be the repo-root + ``routines/``, which git tracks and upstream actively maintains, so + ``create_routine`` and ``edit_routine`` wrote into a directory a later + ``git pull`` would want to change. Now that the write target is + ``/condor/routines``, anything the product already wrote there has to + come with it, or it stops being found. + + **Untracked files only.** A file git knows about is either shipped or a + modification of something shipped, and moving it would silently take the + operator's edit out of the tree the update is about to touch -- the + ``keep-mine`` resolution exists for exactly that case, and it is theirs to + choose. Anything already present in the destination is left alone. + """ + library = paths._PROJECT_ROOT / "routines" + if not library.is_dir(): + return + + try: + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard", "--", "routines"], + cwd=str(paths._PROJECT_ROOT), + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + log.debug("Could not list untracked routines; skipping the lift") + return + if result.returncode != 0: + return + + destination = paths.local_agents_root() / "condor" / "routines" + for line in result.stdout.splitlines(): + rel = line.strip() + if not rel.endswith(".py") or rel.endswith("__init__.py"): + continue + source = paths._PROJECT_ROOT / rel + if not source.is_file(): + continue + target = destination / Path(rel).relative_to("routines") + if target.exists(): + continue + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(target)) + except OSError: + log.warning("Could not lift %s into the local root", rel, exc_info=True) + continue + report.chat_routines += 1 + + def ensure_migrated(agents_root: Path | None = None) -> MigrationReport: """Bring this install onto ``.condor/``. Safe to call on every boot. @@ -253,6 +310,17 @@ def ensure_migrated(agents_root: Path | None = None) -> MigrationReport: return report _write_marker(root, MARKER_V5_FILENAME, "FEAT-128") + # v6 rather than a second v5: FEAT-128 landed on main under the same marker + # while this was in review, and two migrations sharing ``.migrated-v5`` + # means whichever writes it first stops the other ever running. + if not (root / MARKER_V6_FILENAME).is_file(): + try: + _lift_chat_routines(report) + except Exception: # noqa: BLE001 - same rule: never block a boot + log.exception("Chat routine lift failed; leaving the library in place") + return report + _write_marker(root, MARKER_V6_FILENAME, "chat-routines-local-layer") + if report.total or report.dropped_stubs: log.warning( "Runtime migrated to %s: %d conversations, %d delegations, " diff --git a/condor/setup_chrome.py b/condor/setup_chrome.py new file mode 100644 index 000000000..2b08f7400 --- /dev/null +++ b/condor/setup_chrome.py @@ -0,0 +1,145 @@ +"""Fetch the headless Chrome kaleido renders charts with, and give up if it hangs. + +``make install`` used to run this as a bare ``python -c "import kaleido; +kaleido.get_chrome_sync()"`` with stderr sent to ``/dev/null``. Three things +went wrong at once when the download stalled: there was no timeout, so it +waited for ever; there was no progress, so nothing distinguished "slow" from +"wedged"; and the only line on screen was ``Setting up Chrome for chart +rendering...``, indefinitely. Measured in that state: 256 KB of a ~150 MB +archive after thirty minutes, the connection open and idle. The install could +not finish and nothing said why. + +Everything here is best effort. Chrome is for chart *images* -- the dashboard, +the trading loops and the update flow all work without it -- so no outcome +below is fatal and the exit status is always zero. What changes is that the +operator is told which of the three things happened. + +The stall detector matters more than the ceiling. A cold download on a slow +link can legitimately run for minutes, so a timeout generous enough not to cut +that off is also generous enough to sit on a dead connection for just as long. +Bytes arriving is the signal that separates them. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +#: Overall ceiling. Generous: this is a ~150 MB archive and some hosts are far +#: from the CDN. The stall detector is what catches a wedged download early. +TIMEOUT = int(os.environ.get("CONDOR_CHROME_TIMEOUT", "900")) +#: How long the archive may not grow before we call it stuck. +STALL = int(os.environ.get("CONDOR_CHROME_STALL", "90")) +_POLL = 2.0 +_REPORT_EVERY = 15.0 + + +def chrome_path() -> Path | None: + """Where kaleido expects the browser, or ``None`` on an unsupported arch.""" + try: + from choreographer.cli._cli_utils import get_chrome_download_path + + return get_chrome_download_path() + except Exception: # noqa: BLE001 - an import failure is "cannot tell" + return None + + +def downloaded_bytes(root: Path) -> int: + """How much of the archive has landed so far. Zero when nothing has.""" + try: + return sum(p.stat().st_size for p in root.rglob("*") if p.is_file()) + except OSError: + return 0 + + +def _download_root() -> Path | None: + try: + from choreographer.cli._cli_utils import default_download_path + + return Path(default_download_path) + except Exception: # noqa: BLE001 + return None + + +def _mb(n: int) -> str: + return f"{n / 1_000_000:.1f} MB" + + +def fetch(timeout: int = TIMEOUT, stall: int = STALL) -> tuple[bool, str]: + """Download Chrome, reporting progress and refusing to wait for ever. + + Returns ``(ok, message)``. The download runs in a child process because + ``get_chrome_sync`` is a blocking call with no timeout of its own: killing + the child is the only way to stop waiting on it. + """ + installed = chrome_path() + if installed is None: + return False, "This platform has no Chrome-for-Testing build; skipped." + if installed.exists(): + return True, f"Already installed at {installed}." + + root = _download_root() + proc = subprocess.Popen( + [sys.executable, "-c", "import kaleido; kaleido.get_chrome_sync()"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + started = last_change = last_report = time.monotonic() + seen = downloaded_bytes(root) if root else 0 + + while proc.poll() is None: + time.sleep(_POLL) + now = time.monotonic() + current = downloaded_bytes(root) if root else 0 + if current != seen: + seen, last_change = current, now + if now - last_report >= _REPORT_EVERY and seen: + print(f" … {_mb(seen)} downloaded", flush=True) + last_report = now + # A stall verdict needs a size to watch. Without a download root the + # counter is pinned at 0, `last_change` never moves, and this would + # kill a download that is progressing perfectly well -- then blame a + # proxy for it. The ceiling below still bounds the wait. + if root is not None and now - last_change >= stall: + proc.kill() + proc.wait() + return False, ( + f"Stalled: {_mb(seen)} downloaded, then nothing for {stall}s. " + "The connection is open but idle — usually a proxy or a " + "firewall holding the CDN. Charts render as tables until this " + "succeeds; retry with `make setup-chrome`." + ) + if now - started >= timeout: + proc.kill() + proc.wait() + return False, ( + f"Gave up after {timeout}s with {_mb(seen)} downloaded. It was " + "still making progress, so the link is just slow: raise the " + "ceiling with `CONDOR_CHROME_TIMEOUT=1800 make setup-chrome`." + ) + + output = (proc.stdout.read() if proc.stdout else "") or "" + if proc.returncode == 0: + return True, f"Installed at {installed}." + tail = "\n".join(output.strip().splitlines()[-5:]) + return False, ( + f"Chrome could not be installed (exit {proc.returncode}). Charts render " + "as tables until it is; retry with `make setup-chrome`." + + (f"\n{tail}" if tail else "") + ) + + +def main() -> int: + """Always zero: an optional renderer must never fail an install.""" + ok, message = fetch() + print(f" {'✓' if ok else '!'} {message}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/condor/updates/__init__.py b/condor/updates/__init__.py index c1c806661..7d6d0166d 100644 --- a/condor/updates/__init__.py +++ b/condor/updates/__init__.py @@ -41,6 +41,7 @@ read_journal, register_observer, relaunch_pending, + request_relaunch, resolve, start, tail, @@ -69,6 +70,7 @@ "read_journal", "relaunch_pending", "repo_blocks", + "request_relaunch", "register_observer", "resolve", "start", diff --git a/condor/updates/components.py b/condor/updates/components.py index b2ad97f4e..b90af2966 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -22,8 +22,10 @@ import asyncio import logging import os +import shutil import time from dataclasses import asdict, dataclass, field +from pathlib import PurePosixPath from typing import Any from utils import updater @@ -59,6 +61,14 @@ class Facet: up_to_date: bool = True detail: list[str] = field(default_factory=list) error: str | None = None + error_code: str = "" + """Which failure this is, for a surface that has to act on it. + + Set beside every ``error``. The alternative was matching on the message + text, which read ``"compose" in image.error`` and quietly filed everything + else -- an image that was never pulled, a service with no image at all -- + under ``registry-unreachable``, naming a cause that had not happened. + """ def to_wire(self) -> dict[str, Any]: return asdict(self) @@ -236,6 +246,7 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: kind="image", current="unknown", up_to_date=False, + error_code="docker-unavailable", error=( "Could not read the compose file — is Docker running? " "(docker compose config failed)" @@ -244,56 +255,99 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: "unknown", ) - mode = "source" if definition.get("build") else "image" + # A ``build:`` key would mean the image is produced here, but no shipped + # compose file has one and hummingbot-api is deployed from the published + # image, so "source" is a mode the product cannot enter. An operator who + # hand-adds a build key is running their own image, which is exactly what + # the checkout gate and the locally-built check below already cover. + mode = "image" image_ref = str(definition.get("image") or "") - if mode == "source": - # Built here: the repo is the version, and there is no registry to ask. - return ( - Facet( - kind="image", - current=image_ref or "(built from source)", - detail=["Built from source; the repo below is the version."], - ), - mode, - ) - if not image_ref: return ( Facet( kind="image", current="unknown", up_to_date=False, + error_code="no-image-declared", error=f"The {service} service declares neither an image nor a build.", ), mode, ) - local, remote = await asyncio.gather( - updater.local_image_digest(image_ref), + (local_id, local), remote = await asyncio.gather( + updater.local_image_identity(image_ref), updater.registry_image_digest(image_ref), ) - if local is None: + if local_id is None: + # "inspect failed" is two answers, and only one of them is about the + # image. `docker image inspect` is the sole probe here that needs the + # daemon -- `compose config` parses locally and `imagetools inspect` + # asks the registry -- so a daemon that is down or restarting reaches + # exactly this branch with everything else looking fine. Reported as a + # missing image, that reads as "you have never deployed", about a + # container that is running. + if not await updater.docker_running(): + return ( + Facet( + kind="image", + current="unknown", + available=_short_digest(remote), + up_to_date=False, + error_code="docker-unavailable", + error=( + "Docker is not answering, so the running image cannot " + "be read. Start Docker (or wait for it to finish " + "restarting) and check again — nothing is wrong with " + "the image." + ), + ), + mode, + ) return ( Facet( kind="image", current="unknown", available=_short_digest(remote), up_to_date=False, + error_code="image-absent", error=( - f"No local digest for {image_ref} — it has never been pulled " - "by tag, so there is nothing to compare." + f"No local copy of {image_ref} — it has never been pulled " + "or built here, so there is nothing to compare." ), ), mode, ) + + if local is None: + # Present, but carrying no registry digest. Under the classic image + # store -- still the default on Docker Engine -- that is exactly what + # `make build` leaves behind, and the check below that would have + # caught it (`registry_has_digest`) never runs, because it needs a + # digest to ask about. Reported as the operator's own image, the same + # answer the containerd store reaches by the other route, rather than + # as an error that blocks the whole update. + return ( + Facet( + kind="image", + current=_short_digest(local_id), + behind=0, + detail=[ + "Built here, or loaded from a tarball: it carries no " + "registry digest, so it is not comparable to the published " + "tag. Rebuild it yourself to pick up changes." + ], + ), + mode, + ) if remote is None: return ( Facet( kind="image", current=_short_digest(local), up_to_date=False, + error_code="registry-unreachable", error=( f"Could not reach the registry for {image_ref}; " "the running version is known, the available one is not." @@ -303,20 +357,142 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: ) same = local == remote + if same: + return ( + Facet(kind="image", current=_short_digest(local), behind=0), + mode, + ) + + # Different digests are not yet evidence of being behind. `make build` in + # hummingbot-api tags `hummingbot/hummingbot-api:latest` -- the *same* tag + # the registry publishes -- so the operator's own build lands here looking + # exactly like an out-of-date pull, and Condor would offer to overwrite it + # with the registry's. Neither the tag nor the presence of a RepoDigest can + # tell the two apart (a build gets a RepoDigest too, from the containerd + # image store). Asking the registry whether it holds *this* digest can. + published = await updater.registry_has_digest(image_ref, local) + if published is None: + # Neither answer, and the two it sits between are not symmetric. Call it + # a pull and we offer to overwrite what may be an operator's `make + # build` -- work that only exists on this host. Call it a build and we + # hide a real update behind a silent "up to date". So it is neither: + # the probe failed, that is a fact about the probe, and it blocks with + # the reason rather than guessing. `registry_has_digest` is deliberate + # about this -- None is "could not ask", reached when the registry + # answered something other than "not found", typically a rate limit or + # an auth error. Retrying is the remedy, and a block says so where an + # offer would not. + return ( + Facet( + kind="image", + current=_short_digest(local), + available=_short_digest(remote), + up_to_date=False, + error_code="image-origin-unknown", + error=( + f"{image_ref} is running a digest the published tag does " + "not resolve to, and the registry would not say whether it " + "published that digest — so this is either an out-of-date " + "pull or an image built here, and the two want opposite " + "things. Check again in a moment; if it persists, the " + "registry is rate-limiting or refusing the lookup." + ), + ), + mode, + ) + if published is False: + return ( + Facet( + kind="image", + current=_short_digest(local), + behind=0, + detail=[ + "Built locally; not comparable to the published tag. " + "Rebuild it yourself to pick up changes." + ], + ), + mode, + ) + return ( Facet( kind="image", current=_short_digest(local), - available=None if same else _short_digest(remote), + available=_short_digest(remote), # An image is behind or it is not; there is no commit count to give. - behind=0 if same else 1, - up_to_date=same, - detail=[] if same else ["A newer image is published under this tag."], + behind=1, + up_to_date=False, + detail=["A newer image is published under this tag."], ), mode, ) +# hummingbot-api's CI publishes only on merges to the default branch, and only +# the `latest` and version tags (verified against the registry: `latest`, +# `1.0.1`, and one stale one-off). There is no per-branch tag, so on any other +# checkout the published image has nothing to do with the code on disk -- +# offering to pull it means silently replacing the container with the default +# branch's code. PRs merge straight to the default branch, so there is no second +# mapping to make: either the checkout is the one the tag is built from, or the +# operator owns the image. +_CANONICAL_REMOTES = ("github.com/hummingbot/hummingbot-api",) + + +def _is_canonical_remote(url: str) -> bool: + """Whether ``url`` is the repository whose merges publish the tag. + + A fork has a default branch too, usually with the same name, so the branch + name alone would claim the published image matches code it has never seen. + Normalized loosely on purpose -- ssh, https, with or without ``.git`` -- and + conservative: anything unrecognized is treated as not canonical, which + skips the image rather than overwriting it. + """ + cleaned = url.strip().removesuffix(".git").replace(":", "/").lower() + return any(cleaned.endswith(r) or f"/{r}" in cleaned for r in _CANONICAL_REMOTES) + + +async def image_tracks_this_checkout(repo_dir: str) -> tuple[bool, str]: + """Whether the published tag can correspond to this checkout, and why not. + + Four conditions, each of which is a way the branch *name* alone gets it + wrong: + + * the remote must be the canonical repository -- a fork's ``main`` is not + the ``main`` the tag is built from; + * ``HEAD`` must not be detached -- ``--abbrev-ref`` answers the literal + string ``HEAD``, which would otherwise read as an ordinary branch name; + * the branch must be the remote's default -- read, not hardcoded; + * the checkout must not be ahead -- unpushed commits mean the branch is the + default one and the code is not. + + Returns ``(True, "")`` when an image update is meaningful, otherwise + ``(False, reason)`` with the reason in words an operator can act on. + """ + rc, url = await updater._run_git("remote", "get-url", "origin", repo_dir=repo_dir) + if rc != 0 or not url: + return False, "this checkout has no origin remote" + if not _is_canonical_remote(url): + return ( + False, + f"origin is {url.strip()}, not the repository the image is built from", + ) + + if await updater.is_detached(repo_dir): + return False, "HEAD is detached, so it is not on any branch" + + default = await updater.remote_default_branch(repo_dir) + branch = await updater.get_current_branch(repo_dir) + if default is None: + return False, "the remote's default branch could not be determined" + if branch != default: + return False, f"this checkout is on `{branch}`, not `{default}`" + + if await updater.ahead_count(repo_dir, branch) > 0: + return False, f"this checkout has commits that are not on `{default}`" + return True, "" + + async def status(key: str) -> ComponentStatus: """Every facet of one component, fetched concurrently.""" component = _table()[key] @@ -330,10 +506,30 @@ async def status(key: str) -> ComponentStatus: up_to_date=repo.up_to_date, ) - repo, (image, mode) = await asyncio.gather( + repo, (image, mode), (tracks, why_not) = await asyncio.gather( _repo_facet(component.repo_dir), _image_facet(component.repo_dir, component.service), + image_tracks_this_checkout(component.repo_dir), ) + + if not tracks: + # The comparison is still *correct* -- it is just meaningless here, and + # acting on it would replace the operator's container with the default + # branch's code. Keep the running digest visible, drop the offer, and + # say why. Stated rather than silently skipped: a silent skip is the + # same class of defect as the silent pull it replaces. + image = Facet( + kind="image", + current=image.current, + behind=0, + detail=[ + f"The container runs the published image, which is built only " + f"from the default branch — and {why_not}. Left alone; you own " + f"this image." + ], + error=image.error, + ) + return ComponentStatus( key=component.key, name=component.name, @@ -418,6 +614,35 @@ async def running_executor_count() -> int | None: # ── Preflight ── +def is_forkable(component_key: str, rel_path: str) -> bool: + """Whether a conflicting path can be kept by moving it to the local root. + + Only Condor's shipped agent library. ``agents//...`` has a local + counterpart that reads shadow per item, so moving an edit there is exactly + what the product would have done with it. Anything else -- ``main.py``, a + compose file, anything in hummingbot-api -- has no such layer, and "keeping" + it by moving it somewhere nothing reads would be a quieter way of losing it + than discarding. + """ + if component_key != CONDOR: + return False + parts = PurePosixPath(rel_path).parts + return len(parts) > 2 and parts[0] == "agents" and not parts[1].startswith("_") + + +def _resolutions_for(component_key: str, paths: list[str]) -> list[str]: + """The ways out of a conflict, with the non-lossy one offered where it works. + + ``keep-mine`` appears only when every conflicting path is forkable. Offering + it on a mixed set would be worse than not offering it: it reads as "keep all + of this", and the paths it could not move would be reset anyway. + """ + base = ["discard", "stash", "cancel"] + if paths and all(is_forkable(component_key, p) for p in paths): + return ["keep-mine", *base] + return base + + def _incoming_unknown(component: Component, branch: str, because: str) -> Block: """The block for "we could not work out what this update would bring in". @@ -460,6 +685,30 @@ async def repo_blocks(component: Component) -> list[Block]: ) ] + # Before anything reads a branch name: a detached checkout has none, but + # ``rev-parse --abbrev-ref HEAD`` answers the literal string ``HEAD``, and + # everything downstream then treats that as a branch. ``origin/HEAD`` + # resolves to the remote's default branch, so the comparison silently + # becomes "how far is this commit behind main", nothing reports being + # ahead, and ``merge --ff-only origin/HEAD`` succeeds -- fast-forwarding a + # detached checkout onto main and leaving it detached, with no message + # saying so. Refuse instead, and say which commit it is sitting on. + if await updater.is_detached(component.repo_dir): + sha = await updater.get_local_commit(component.repo_dir) + return [ + Block( + component=component.key, + code="detached-head", + message=( + f"HEAD is detached at {sha} in {component.repo_dir}. " + "Check out a branch before updating — otherwise the update " + "would fast-forward onto the remote's default branch and " + "leave the checkout detached." + ), + resolutions=["cancel"], + ) + ] + branch = await updater.get_current_branch(component.repo_dir) # Fetch first, always. The incoming set is only meaningful against an @@ -468,8 +717,28 @@ async def repo_blocks(component: Component) -> list[Block]: # ref is how local work gets clobbered by commits nobody had seen yet. So a # fetch that failed is not a detail to shrug off: it is the whole basis of # the comparison below, gone. - fetched, _ = await updater.fetch(component.repo_dir) + fetched, fetch_output = await updater.fetch(component.repo_dir) if not fetched: + if updater.is_missing_remote_ref(fetch_output): + # Not an unreachable remote: the remote answered and has no branch + # of this name. Saying "unreachable" sent the reader to check a + # network that was never the problem, and `incoming-unknown` is the + # wrong shape too -- nothing is incoming, there is no upstream. + default = await updater.remote_default_branch(component.repo_dir) + switch = f" (`git switch {default}`)" if default else "" + return [ + Block( + component=component.key, + code="no-upstream-branch", + message=( + f"`{branch}` exists only in this checkout — origin has " + "no branch of that name, so there is nothing to update " + "from. Push it to set an upstream, or switch to a " + f"branch that tracks origin{switch}." + ), + resolutions=["cancel"], + ) + ] return [_incoming_unknown(component, branch, "the remote is unreachable")] ahead, dirty, incoming = await asyncio.gather( @@ -517,7 +786,7 @@ async def repo_blocks(component: Component) -> list[Block]: "commits." ), paths=tracked, - resolutions=["discard", "stash", "cancel"], + resolutions=_resolutions_for(component.key, tracked), ) ) if untracked: @@ -530,7 +799,7 @@ async def repo_blocks(component: Component) -> list[Block]: "not in git would be overwritten by the incoming commits." ), paths=untracked, - resolutions=["discard", "stash", "cancel"], + resolutions=_resolutions_for(component.key, untracked), ) ) return blocks @@ -541,19 +810,28 @@ def _steps_for(component_key: str, status_: ComponentStatus) -> list[str]: steps: list[str] = [] if component_key == HUMMINGBOT_API: repo = status_.facets.get("repo") + image = status_.facets.get("image") if repo is not None and not repo.up_to_date and repo.error is None: - steps.append("Fast-forward the hummingbot-api checkout") - if status_.mode == "source": - steps.append("Rebuild the hummingbot-api image from source") - else: - steps.append("Pull the published hummingbot-api image") + # Worth saying what this is *for*: the checkout supplies the shared + # files the container reads, so it is fast-forwarded even when the + # image is left alone. + steps.append("Fast-forward the hummingbot-api checkout (shared files)") + if image is not None and image.up_to_date: + # Either already current, or an image this checkout does not track. + # Nothing to pull, and nothing to restart for. + return steps + steps.append("Pull the published hummingbot-api image") steps.append("Recreate the containers") steps.append("Wait for the API to answer") else: + # Must stay in step with ``run._plan``: the confirm screen and the + # progress screen are meant to be the same list. steps.append("Fast-forward the Condor checkout") + steps.append("Restore any work stashed to clear the way") steps.append("Sync dependencies") steps.append("Rebuild the dashboard if the update touched it") - steps.append("Ask you to relaunch Condor to apply it") + steps.append("Check the install over") + steps.append("Relaunch Condor to apply it") return steps @@ -582,6 +860,172 @@ async def _executor_warning() -> Warning | None: ) +_STALE_FORK_CAP = 8 + + +# An update can write a rebuilt venv, a fresh node_modules, a new bundle and a +# pulled image. Running out part-way is the worst input to everything else here, +# and nothing checked: shutil.disk_usage appeared nowhere in the update path. +_DISK_FLOOR_GB = 3.0 + + +def _locally_overridden(incoming: list[str]) -> list[str]: + """:func:`condor.layering.locally_overridden`, but never fatal to a preflight.""" + from condor.layering import locally_overridden + + try: + return locally_overridden(incoming) + except OSError: + log.debug("Could not check for locally overridden paths", exc_info=True) + return [] + + +def _incoming_warnings(incoming: list[str]) -> list[Warning]: + """Consequences that follow from *which files* an update would write.""" + out: list[Warning] = [] + if any(p.startswith("frontend/") for p in incoming): + # frontend_needs_build() is consulted inside the run and never in the + # preflight, so nothing told the person reading the dashboard that the + # page they are on is the thing about to be replaced. + out.append( + Warning( + component=CONDOR, + code="frontend-will-rebuild", + message=( + "This update rebuilds the dashboard. The new bundle is built " + "alongside the running one and swapped in, so the page you " + "are reading keeps working — reload it once the update " + "finishes to pick up the new one." + ), + ) + ) + overridden = _locally_overridden(incoming) + if overridden: + # The predictive half. stale_forks() compares a fork against the stock + # file on disk, which only moves once the fast-forward has landed -- so + # on its own it reports the divergence *after* the update that caused + # it. This says so while there is still a decision to make. + shown = ", ".join(overridden[:_STALE_FORK_CAP]) + if len(overridden) > _STALE_FORK_CAP: + shown += f", and {len(overridden) - _STALE_FORK_CAP} more" + out.append( + Warning( + component=CONDOR, + code="update-changes-overridden-files", + message=( + f"This update changes {len(overridden)} shipped " + f"file{'s' if len(overridden) != 1 else ''} that you have " + f"your own version of ({shown}). Your versions will keep " + "being used, so these changes will not reach your agents." + ), + ) + ) + if "condor/migrations.py" in incoming: + # startup() migrates this deployment's data on every boot and the moves + # are one-way, so restoring old *code* onto migrated *data* leaves + # Condor looking for directories that no longer exist. FEAT-115 widened + # that to the agent tree, so a rollback now also strands forked + # playbooks, skills and mutes. + out.append( + Warning( + component=CONDOR, + code="migration-incoming", + message=( + "This update migrates stored data, and the moves are " + "one-way. Rolling back to the current commit afterwards " + "will not restore the old layout." + ), + ) + ) + return out + + +def _disk_warning(repo_dir: str) -> Warning | None: + """Warn when there is not obviously room for what the update is about to write.""" + try: + usage = shutil.disk_usage(repo_dir) + except OSError: + log.debug("Could not read free space for %s", repo_dir, exc_info=True) + return None + free_gb = usage.free / (1024**3) + if free_gb >= _DISK_FLOOR_GB: + return None + return Warning( + component=CONDOR, + code="low-disk", + message=( + f"Only {free_gb:.1f} GB free where Condor is installed. An update " + f"writes a synced virtualenv, a fresh node_modules and a new " + f"dashboard bundle — running out part-way through leaves the " + f"install half-built. Free some space first." + ), + ) + + +def _stale_fork_warning() -> Warning | None: + """Local versions of shipped files that this install will keep using. + + Non-blocking on purpose. Owning a customization is what FEAT-115 shipped; + what never shipped is the notice, so the divergence compounded silently on + files carrying trading rules. This is that notice. + + The path list is capped -- an install that has forked thirty playbooks needs + a number and a few examples, not thirty lines in a Telegram message. + """ + from condor.layering import all_stale_forks + + try: + stale = all_stale_forks() + except OSError: + log.debug("Could not check for stale forks", exc_info=True) + return None + if not stale: + return None + + moved = [f for f in stale if not f.retired and not f.unprovenanced] + shadowing = [f for f in stale if not f.retired and f.unprovenanced] + retired = [f for f in stale if f.retired] + + parts: list[str] = [] + if shadowing: + # Separated because the advice differs: a stamped fork was deliberately + # taken from a known version, while these never recorded one -- a + # routine's .py cannot hold a stamp, and a file the agent created under + # a name upstream later shipped never had one to take. Either way the + # local copy wins and upstream's is unreachable. + shown = ", ".join(f.label for f in shadowing[:_STALE_FORK_CAP]) + if len(shadowing) > _STALE_FORK_CAP: + shown += f", and {len(shadowing) - _STALE_FORK_CAP} more" + parts.append( + f"{len(shadowing)} local file{'s' if len(shadowing) != 1 else ''} " + f"shadow{'' if len(shadowing) != 1 else 's'} a shipped one of the " + f"same name and differ{'' if len(shadowing) != 1 else 's'} from it " + f"({shown}). Yours are used; upstream's are not reachable. Rename " + "yours if you meant to keep both." + ) + if moved: + shown = ", ".join(f.label for f in moved[:_STALE_FORK_CAP]) + if len(moved) > _STALE_FORK_CAP: + shown += f", and {len(moved) - _STALE_FORK_CAP} more" + parts.append( + f"You have local versions of {len(moved)} shipped " + f"file{'s' if len(moved) != 1 else ''} that upstream has since " + f"changed ({shown}). Your versions stay in use, so those changes " + "will not reach your agents." + ) + if retired: + shown = ", ".join(f.label for f in retired[:_STALE_FORK_CAP]) + if len(retired) > _STALE_FORK_CAP: + shown += f", and {len(retired) - _STALE_FORK_CAP} more" + parts.append( + f"{len(retired)} local file{'s' if len(retired) != 1 else ''} " + f"no longer exist{'' if len(retired) != 1 else 's'} upstream " + f"({shown}) and will keep running. Mute a retired playbook rather " + "than deleting it." + ) + return Warning(component=CONDOR, code="stale-forks", message=" ".join(parts)) + + async def preflight(component_keys: list[str]) -> Preflight: """Can this update run, what will it do, and what should the admin know.""" known = _table() @@ -623,11 +1067,7 @@ async def preflight(component_keys: list[str]) -> Preflight: blocks.append( Block( component=key, - code=( - "docker-unavailable" - if "compose" in image.error - else "registry-unreachable" - ), + code=image.error_code or "registry-unreachable", message=image.error, resolutions=["cancel"], ) @@ -635,10 +1075,69 @@ async def preflight(component_keys: list[str]) -> Preflight: steps.extend(_steps_for(key, current)) if HUMMINGBOT_API in selected: + hb = known[HUMMINGBOT_API] + owner = await updater.compose_stack_owner(hb.repo_dir, HB_SERVICE) + if owner is not None: + # Recreating from here would adopt another checkout's containers + # and its postgres volume, which Compose does without complaint -- + # it matches on the fixed `container_name:` and reports `Recreate`. + # Condor points at a *directory*; Compose acts on a *project*, and + # they are not the same thing when two checkouts share a basename. + blocks.append( + Block( + component=HUMMINGBOT_API, + code="foreign-stack", + message=( + f"The {HB_SERVICE} containers on this host were " + f"deployed from {owner}, not from {hb.repo_dir}. " + "Recreating them from here would take that stack over, " + "along with its database. Point HUMMINGBOT_API_DIR at " + "the checkout that owns them, or stop that stack first." + ), + resolutions=["cancel"], + ) + ) executor_warning = await _executor_warning() if executor_warning is not None: warnings.append(executor_warning) if CONDOR in selected: + condor_status = statuses.get(CONDOR) + repo = condor_status.facets.get("repo") if condor_status else None + if repo is not None and not repo.up_to_date: + # What this update would actually write, rather than a diff against + # an empty commit -- ``paths_changed`` answers True for an + # unresolvable range by design, so asking it that way would make + # both warnings below fire on every update. + incoming = await updater.incoming_paths(_table()[CONDOR].repo_dir) + for warning in _incoming_warnings(incoming or []): + warnings.append(warning) + disk_warning = _disk_warning(_table()[CONDOR].repo_dir) + if disk_warning is not None: + warnings.append(disk_warning) + + stale_warning = _stale_fork_warning() + if stale_warning is not None: + warnings.append(stale_warning) + + if await updater.has_update_stash(_table()[CONDOR].repo_dir): + # Work a previous update parked and could not replay. Nothing said + # so afterwards: the run ended, the step scrolled away, and the + # stash sat there -- invisible to the next preflight, which only + # looks at the working tree, and to `git status`, which does not + # mention stashes at all. + warnings.append( + Warning( + component=CONDOR, + code="stashed-work", + message=( + "An earlier update parked local work in a stash and it " + "is still there — `git stash list` shows it as " + "`condor /update`. This update will try to replay it " + "again, and will put the checkout back untouched if it " + "still does not apply." + ), + ) + ) warnings.append( Warning( component=CONDOR, diff --git a/condor/updates/run.py b/condor/updates/run.py index 85eeaf335..8e8595ef5 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -1,14 +1,23 @@ """One update run: its steps, its journal, and who is watching it. -A Condor update ends one step short of running the new code. It used to -``exec`` the process at the end, and that is the one part of an update Condor -cannot do safely: it is almost never the top of its own process tree. Started -through ``make run``, a shell wrapper or a supervisor, re-execing races the -parent into bringing a *second* Condor up on the same port, against the same -config and the same Telegram token. So the run stops at the last safe point — -code on disk, dependencies synced, dashboard rebuilt — records that a relaunch -is owed (:func:`relaunch_pending`), and lets a human do the one thing a human -is better placed to do. The surfaces turn that record into a banner. +A Condor update ends one step short of running the new code: code on disk, +dependencies synced, dashboard rebuilt, and a record that a relaunch is owed +(:func:`relaunch_pending`), which the surfaces turn into a banner. + +The reason is *when*, not whether. This used to say that exec'ing was the one +part of an update Condor could not do safely — that, started under ``make +run``, a shell wrapper or a supervisor, it would race the parent into a second +Condor on the same port. That is not what the code does. +:func:`utils.updater.request_restart` raises SIGTERM rather than exec'ing, so +``teardown()`` runs and ``main()`` execs only once the loop is gone; the exec +replaces the process image in place, keeping the pid, the parent and the +controlling terminal, and the listening socket is non-inheritable (PEP 446), so +there is no second copy and nothing to rebind around. + +What a run genuinely cannot choose is the moment. Bots are trading, positions +are open, and a chat session may be mid-answer — so the run finishes its work +and hands the timing to whoever is watching. Both surfaces offer the button +that takes it. The durable record is still the contract: every step transition is one ``atomic_write_json`` to ``data/update_run.json``, and both surfaces read that @@ -25,14 +34,18 @@ from __future__ import annotations import asyncio +import fcntl +import io import json import logging +import os import time from dataclasses import dataclass, field from typing import Any, Awaitable, Callable from condor import paths from condor.fsutil import atomic_write_json +from condor.paths import runtime_root from condor.updates import components from utils import updater @@ -50,6 +63,11 @@ PENDING = "pending" OK = "ok" SKIPPED = "skipped" +# Ran, did not fail the update, but the operator needs to read the output -- +# a stash that would not pop, a doctor check that came back unhappy. Distinct +# from OK because "the update succeeded" must not swallow "and here is what is +# still wrong", and distinct from FAILED because neither aborts the run. +WARNED = "warned" # Command output kept per step. Enough to diagnose, far under Telegram's limit. OUTPUT_TAIL_CHARS = 2000 @@ -248,6 +266,21 @@ async def _emit(run: Run) -> None: _relaunch: dict[str, Any] | None = None +def request_relaunch() -> None: + """Apply a pending update by restarting Condor in place. + + A one-line pass-through to :func:`utils.updater.request_restart`, and it + earns its place: the surfaces are views over this package and do not import + ``utils`` directly, so without it the web route would have to reach past the + engine to do the one thing the engine exists to coordinate. + + Not an exec. ``request_restart`` raises SIGTERM, ``teardown()`` runs, and + ``main()`` execs once the loop is gone -- so persistence is flushed, agent + loops record their final state and ACP/MCP children are not orphaned. + """ + updater.request_restart() + + def relaunch_pending() -> dict[str, Any] | None: """What this process is missing, or ``None`` if it is running the update. @@ -303,6 +336,13 @@ async def resolve(component_key: str, action: str) -> tuple[bool, str]: ok, message = await updater.discard_paths(component.repo_dir, conflicting) elif action == "stash": ok, message = await updater.stash_paths(component.repo_dir, conflicting) + elif action == "keep-mine": + # Re-checked here rather than trusted from the button: the block that + # offered this may be minutes old, and moving a path that is not + # forkable would put it somewhere nothing reads. + if not all(components.is_forkable(component_key, p) for p in conflicting): + return False, "Those files cannot be kept this way." + ok, message = await updater.move_to_local_root(component.repo_dir, conflicting) else: return False, f"Unknown resolution: {action}" @@ -324,31 +364,91 @@ def _plan(component_keys: list[str], statuses: dict[str, components.ComponentSta continue if key == components.HUMMINGBOT_API: status = statuses.get(key) - mode = status.mode if status else "image" steps.append( Step(f"{key}.fast-forward", "Fast-forwarding the hummingbot-api repo") ) - steps.append( - Step( - f"{key}.image", - ( - "Rebuilding the API image" - if mode == "source" - else "Pulling the API image" - ), - ) - ) - steps.append(Step(f"{key}.up", "Recreating the containers")) - steps.append(Step(f"{key}.health", "Waiting for the API to answer")) + # The image steps only exist when the published tag can correspond + # to this checkout. Off the default branch the container is the + # operator's, so there is nothing to pull and nothing to restart + # for -- and a plan that listed them anyway would be promising to do + # something the run then skips. + image = status.facets.get("image") if status else None + if image is None or not image.up_to_date: + steps.append(Step(f"{key}.image", "Pulling the API image")) + steps.append(Step(f"{key}.up", "Recreating the containers")) + steps.append(Step(f"{key}.health", "Waiting for the API to answer")) else: # No restart step: the run stops with the new code on disk and asks # for the relaunch instead (see the module docstring). steps.append(Step(f"{key}.fast-forward", "Fast-forwarding Condor")) + steps.append(Step(f"{key}.unstash", "Restoring stashed work")) steps.append(Step(f"{key}.deps", "Syncing dependencies")) steps.append(Step(f"{key}.frontend", "Rebuilding the dashboard")) + steps.append(Step(f"{key}.doctor", "Checking the install over")) return steps +_LOCK_FILENAME = "update.lock" +# One handle per process. flock conflicts between two descriptors even in the +# same process, so re-acquiring would have this process refusing itself -- and +# within a process ``_lock``/``_current`` already answer the question. +_lock_handle: "io.TextIOWrapper | None" = None + + +def _acquire_run_lock() -> "io.TextIOWrapper | None": + """Take an advisory lock for the length of a run, across processes. + + ``_lock`` and ``_current`` guard this process only. A second Condor on the + same checkout -- a stray ``make run`` in another pane, which is exactly what + someone does when a restart looks stuck -- would fast-forward the same repo + concurrently with the first, and neither would know. + + Advisory and best effort: if the lock cannot be taken *because someone holds + it*, the caller refuses; if locking is unavailable at all, the update + proceeds as before rather than being blocked by the safety net. + """ + path = runtime_root() / _LOCK_FILENAME + try: + path.parent.mkdir(parents=True, exist_ok=True) + # "a+" rather than "w". "w" truncates at *open*, before the lock is even + # attempted, so the process that loses the race wipes the pid written by + # the one holding it -- and ``start`` then reads an empty file and drops + # the "(pid N)" the refusal exists to carry. The file is emptied below, + # once this process is the one entitled to write it. + handle = path.open("a+") + except OSError: + log.debug("Could not open the update lock; proceeding unlocked") + return None + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return None + except (AttributeError, NameError): # pragma: no cover - no flock here + return handle + try: + handle.seek(0) + handle.truncate() + handle.write(f"{os.getpid()}\n") + handle.flush() + except OSError: + pass + return handle + + +def _release_run_lock(handle) -> None: + if handle is None: + return + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except (OSError, AttributeError, NameError): + pass + try: + handle.close() + except OSError: + pass + + async def start( component_keys: list[str], *, @@ -367,19 +467,46 @@ async def start( if _current is not None and _current.live: return _current - selected = [k for k in components.keys() if k in set(component_keys)] - statuses = {s.key: s for s in await components.check()} + global _lock_handle + if _lock_handle is None: + _lock_handle = _acquire_run_lock() + if _lock_handle is None and (runtime_root() / _LOCK_FILENAME).exists(): + holder = "" + try: + holder = (runtime_root() / _LOCK_FILENAME).read_text().strip() + except OSError: + pass + raise RuntimeError( + "Another Condor process on this checkout is already running " + f"an update{f' (pid {holder})' if holder else ''}. Wait for " + "it to finish rather than running a second one over the " + "same repo." + ) - run = Run( - id=f"u-{int(time.time())}", - started=time.time(), - actor={"user_id": actor_user_id, "chat_id": actor_chat_id}, - components=selected, - steps=_plan(selected, statuses), - ) - _current = run - _task = asyncio.create_task(_execute(run, resolutions or {})) - return run + # From here to the task existing, nothing else will release the lock: + # `_execute`'s finally clause is the only place that does, and there is + # no `_execute` yet. `check()` shells out to git and docker, so it can + # raise for reasons that have nothing to do with this checkout -- and + # the lock would then be held by a process that is not updating, and + # not released until it exits. + try: + selected = [k for k in components.keys() if k in set(component_keys)] + statuses = {s.key: s for s in await components.check()} + + run = Run( + id=f"u-{int(time.time())}", + started=time.time(), + actor={"user_id": actor_user_id, "chat_id": actor_chat_id}, + components=selected, + steps=_plan(selected, statuses), + ) + _current = run + _task = asyncio.create_task(_execute(run, resolutions or {})) + return run + except BaseException: + _release_run_lock(_lock_handle) + _lock_handle = None + raise async def _begin(run: Run, key: str) -> Step | None: @@ -439,6 +566,10 @@ async def _execute(run: Run, resolutions: dict[str, str]) -> None: except Exception as e: # noqa: BLE001 - an update must never die silently log.exception("Update run %s crashed", run.id) await _fail(run, f"{type(e).__name__}: {e}") + finally: + global _lock_handle + _release_run_lock(_lock_handle) + _lock_handle = None async def _update_hb_api(run: Run) -> bool: @@ -464,17 +595,9 @@ async def _update_hb_api(run: Run) -> bool: await _fail(run, "The hummingbot-api checkout could not be moved.") return False - mode = await updater.compose_mode(component.repo_dir, component.service) step = await _begin(run, f"{prefix}.image") if step is not None: - if mode == "source": - ok, output = await updater.compose_build( - component.repo_dir, component.service - ) - else: - ok, output = await updater.compose_pull( - component.repo_dir, component.service - ) + ok, output = await updater.compose_pull(component.repo_dir, component.service) await _finish(run, step, OK if ok else FAILED, output) if not ok: await _fail(run, "The API image could not be produced.") @@ -482,6 +605,18 @@ async def _update_hb_api(run: Run) -> bool: step = await _begin(run, f"{prefix}.up") if step is not None: + # Re-asked here, not just at preflight: adopting another checkout's + # containers takes its database with them, and the screen the operator + # confirmed on may be minutes old. + owner = await updater.compose_stack_owner(component.repo_dir, component.service) + if owner is not None: + message = ( + f"The {component.service} containers belong to {owner}, not to " + f"{component.repo_dir}. Refusing to recreate them from here." + ) + await _finish(run, step, FAILED, message) + await _fail(run, message) + return False ok, output = await updater.compose_up(component.repo_dir) await _finish(run, step, OK if ok else FAILED, output) if not ok: @@ -529,6 +664,19 @@ async def _update_condor(run: Run) -> bool: await _fail(run, "Condor could not be fast-forwarded.") return False + # Work parked by the `stash` resolution goes back now that the fast-forward + # has landed. Stashing was never meant to be where it ended -- the point of + # parking work rather than discarding it is getting it back, and the common + # case applies cleanly. A conflicting pop does not fail the update: the + # stash is intact either way, and the step says so. + step = await _begin(run, f"{prefix}.unstash") + if step is not None: + popped, output = await updater.stash_pop(component.repo_dir) + if "Nothing of ours" in output: + await _finish(run, step, SKIPPED, output) + else: + await _finish(run, step, OK if popped else WARNED, output) + after = await updater.get_local_commit_full(component.repo_dir) run.target_commit = after @@ -555,11 +703,22 @@ async def _update_condor(run: Run) -> bool: if not ok: await _fail( run, - "Code and deps are updated, but the dashboard would come " - "back on the previous bundle.", + "Code and deps are updated, but the dashboard could not be " + "rebuilt — so it is still serving the bundle it was " + "serving before, which is intact. The build writes to " + "frontend/dist.new and only swaps it in on success.", ) return False + # Check the install over before declaring victory. Read-only, and never + # fatal: the code is already on disk, so failing the run on the doctor's + # verdict would report a completed update as a failed one. WARNED is the + # honest state -- the update did happen, and there is something to read. + step = await _begin(run, f"{prefix}.doctor") + if step is not None: + ok, output = await updater.run_doctor() + await _finish(run, step, OK if ok else WARNED, output) + # Everything that can be done without ending the process is done. The one # remaining step belongs to a human, so the run is judged here rather than # at the next boot: it succeeded, and it owes a relaunch. @@ -620,10 +779,21 @@ async def finalize_pending_run() -> Run | None: if not was_restarting: run.state = FAILED where = f" during {step.label.lower()}" if step is not None else "" + done = [s.label.lower() for s in run.steps if s.state == OK] + completed = ( + f" What did finish: {', '.join(done)}." if done else " Nothing finished." + ) + # The old wording described the state of the *disk* accurately and left + # out the part that matters now: the process reading this booted on the + # partial result. The fast-forward lands before dependencies are synced + # and before the dashboard is rebuilt, so this Condor may be running new + # code against a stale venv. run.error = ( - f"This update was interrupted{where} — Condor stopped before the " - "run finished. Nothing was rolled back; start it again to pick up " - "wherever it got to." + f"This update was interrupted{where} — Condor stopped before the run " + f"finished.{completed} Nothing was rolled back, so Condor is now " + "*running* that partial result: new code, possibly with dependencies " + "or a dashboard bundle that never caught up. Run the update again " + "before anything else." ) if step is not None: step.state = FAILED diff --git a/condor/web/app.py b/condor/web/app.py index d4709bb5d..e41d9c415 100644 --- a/condor/web/app.py +++ b/condor/web/app.py @@ -1,12 +1,13 @@ from __future__ import annotations +import logging import os from pathlib import Path from urllib.parse import urlparse from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from condor.telemetry.taps import web_tap @@ -62,6 +63,8 @@ def _build_cors_origins() -> list[str]: # The SPA shell and every unhashed file beside it: revalidate on each load. # Cheap — a 304 — and the only thing that guarantees a refresh is looking at the # build that is actually installed. +log = logging.getLogger(__name__) + _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} @@ -81,6 +84,39 @@ def file_response(self, *args, **kwargs): # type: ignore[override] return response +def _adopt_orphaned_bundle(dist: Path) -> None: + """Put a bundle back at ``dist`` if the swap was interrupted mid-rename. + + ``build_frontend`` builds into ``dist.new`` and swaps: + ``mv dist dist.old && mv dist.new dist``. Killed between those two renames + — a reboot, an OOM kill, a restart — the tree has a perfectly good + ``dist.new`` and ``dist.old`` and no ``dist``. That matters more than the + window suggests, because the static mount *and* the SPA fallback both sit + inside a single ``dist.is_dir()`` evaluated once here at startup: with no + ``dist`` Condor comes up serving the API and a bare 404 at ``/``, and + nothing recovers it without a human. + + ``dist.new`` is preferred: the build that produced it had already succeeded, + so it is the newer of the two. + """ + if dist.is_dir(): + return + for candidate in (dist.with_name("dist.new"), dist.with_name("dist.old")): + if not candidate.is_dir(): + continue + try: + candidate.rename(dist) + except OSError: + log.warning("Could not adopt %s as the dashboard bundle", candidate) + continue + log.warning( + "Dashboard bundle was missing; adopted %s. A frontend build was " + "interrupted mid-swap.", + candidate.name, + ) + return + + def create_app() -> FastAPI: app = FastAPI(title="Condor Dashboard API", version="0.1.0") @@ -143,6 +179,7 @@ def create_app() -> FastAPI: # ── Serve built frontend (production) ── dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" + _adopt_orphaned_bundle(dist) if dist.is_dir(): index_html = dist / "index.html" dist_root = dist.resolve() @@ -189,6 +226,18 @@ async def serve_spa(request: Request, full_path: str): # stale *build*: a reader who refreshed the chat got a UI from # before the routine library existed — its "Browse all" gone and # `/routines` back in the nav — with no way to tell it was old. + if not index_html.exists(): + # The bundle is mid-swap, or a build left it absent. Starlette + # raises inside FileResponse on a missing path, which surfaces + # as an opaque 500 that nothing upstream can tell from a crash. + # 503 is what this actually is, and it says so. + return PlainTextResponse( + "The dashboard bundle is not in place — it is being " + "rebuilt, or a build failed. The API is unaffected. Retry " + "in a moment, or run `make restart` if it persists.", + status_code=503, + headers=_NO_CACHE, + ) return FileResponse(index_html, headers=_NO_CACHE) return app diff --git a/condor/web/auth.py b/condor/web/auth.py index 78ede7366..02affa482 100644 --- a/condor/web/auth.py +++ b/condor/web/auth.py @@ -146,10 +146,27 @@ def require_admin(user: WebUser) -> None: copy. """ if not get_config_manager().is_admin(user.id): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin access required", - ) + # Name the role rather than the screen. Local mode admits both `user` + # and `admin` at login while every admin route demands `admin`, so a + # hand-edited config.yml produces an install that boots, logs in, and + # silently has no Updates tab — with nothing anywhere saying why. + # Best effort: naming the role is a courtesy, and a gate must never + # fail open (or crash) because the courtesy could not be paid. + try: + found = get_config_manager().get_user_role(user.id) + # ``get_user_role`` hands back the enum, and interpolating that + # renders "UserRole.USER" — a name that appears nowhere in + # config.yml, so the reader cannot search for it and the value it + # tells them to change looks nothing like the one on disk. + role = str(getattr(found, "value", found) or "") + except Exception: # noqa: BLE001 + role = "" + detail = "Admin access required" + if role: + detail += ( + f" — this user's role is '{role}'. Set it to 'admin' in config.yml." + ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) def report_owner_filter(user: WebUser) -> int | None: diff --git a/condor/web/routes/agents.py b/condor/web/routes/agents.py index 02a19eeb5..885be21cd 100644 --- a/condor/web/routes/agents.py +++ b/condor/web/routes/agents.py @@ -55,7 +55,7 @@ ) from condor.agents.strategy import LOOP_MD, LOOPS_DIRNAME from condor.fsutil import atomic_write_text -from condor.layering import fork_if_stock +from condor.layering import fork_if_stock, write_preserving_stamp from condor.web.auth import ( check_server_access, get_current_user, @@ -2296,8 +2296,11 @@ async def update_agent_md( ) # Straight past ``AgentStore``, so the stock guard is stated here rather # than inherited: a shipped AGENT.md is forked into the local root first and - # this writes the fork (FEAT-115). - atomic_write_text(fork_if_stock(agent.slug, "AGENT.md"), req.content) + # this writes the fork (FEAT-115). The write has to go through + # ``write_preserving_stamp`` -- ``req.content`` came from a GET that read the + # stock file, which carries no stamp, so a plain write erases the one the + # fork just gained. + write_preserving_stamp(fork_if_stock(agent.slug, "AGENT.md"), req.content) return {"updated": True} @@ -2848,9 +2851,10 @@ async def update_strategy_md( new_defaults.get("server_name") if isinstance(new_defaults, dict) else None, (strategy.default_config or {}).get("server_name") or "", ) - # Same as ``update_agent_md``: past ``StrategyStore``, so the fork is here. + # Same as ``update_agent_md``: past ``StrategyStore``, so the fork is here, + # and the stamp has to be carried across the write for the same reason. target = fork_if_stock(slug, LOOPS_DIRNAME, strategy.slug, LOOP_MD) - atomic_write_text(target, req.content) + write_preserving_stamp(target, req.content) return {"updated": True} diff --git a/condor/web/routes/updates.py b/condor/web/routes/updates.py index 45fbbb2c2..e34f55d8a 100644 --- a/condor/web/routes/updates.py +++ b/condor/web/routes/updates.py @@ -26,7 +26,7 @@ import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from condor import updates @@ -152,3 +152,40 @@ async def dismiss_run( require_admin(user) run = updates.acknowledge_run(body.run_id if body else "") return {"run": run.to_wire() if run is not None else None} + + +@router.post("/relaunch", status_code=202) +async def post_relaunch(user: WebUser = Depends(get_current_user)) -> dict: + """Apply a finished update by restarting Condor in place. + + The Local surface could discover, preflight, resolve and apply an update + entirely in the browser, and then had to tell the operator to go and run + ``make restart`` — the one step that makes any of it take effect. Telegram + has had a button for this all along, and it makes exactly this call. + + The asymmetry used to be explained as a safety trade: that re-execing races + whatever started Condor into a second copy on the same port. That is not + what the code does. ``request_restart()`` raises SIGTERM rather than + exec'ing, so ``teardown()`` runs and ``main()`` execs only once the loop is + gone; the exec then replaces the process image in place, keeping the pid, + the parent and the controlling terminal, so nothing exits and there is no + second copy. The listening socket is non-inheritable (PEP 446) and released + by the exec, so the successor rebinds cleanly. + + 202, not 200: the answer is "accepted", and the process serving it is about + to be replaced. The panel already knows how to poll across that — it is what + ``GET /updates/run`` was built for. + """ + require_admin(user) + pending = updates.relaunch_pending() + if pending is None: + raise HTTPException( + status_code=409, + detail="Nothing to apply — this process is already running the code on disk.", + ) + log.info("Relaunch requested from the dashboard by user %s", user.id) + updates.request_relaunch() + return { + "relaunching": True, + "target_commit": (pending.get("target_commit") or "")[:7], + } diff --git a/frontend/src/components/RelaunchBanner.test.tsx b/frontend/src/components/RelaunchBanner.test.tsx new file mode 100644 index 000000000..aa99a3254 --- /dev/null +++ b/frontend/src/components/RelaunchBanner.test.tsx @@ -0,0 +1,162 @@ +/** + * What actually makes an open tab pick up the rebuilt bundle. + * + * The update run rebuilds `frontend/dist` and swaps it in while the old process + * is still serving, so every open tab is left holding a bundle whose chunks + * have moved. Two separate things have to happen for that to resolve, and both + * used to be wrong in a way no test would have caught. + * + * Needs a DOM, so this file overrides vitest's default `node` environment. + * + * @vitest-environment jsdom + */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Data = { required: boolean; from_commit?: string; target_commit?: string }; + +let data: Data | undefined; + +vi.mock("@/hooks/useRelaunch", () => ({ + useReloadAfterRelaunch: () => ({ data, isSuccess: data !== undefined }), +})); + +const relaunch = vi.fn(async () => ({ relaunching: true })); +vi.mock("@/lib/updates-api", () => ({ + updatesApi: { relaunch: () => relaunch() }, +})); + +/** Flipped per test: a 403 is the one rejection that means "and it never will". */ +let forbidden = false; +vi.mock("@/lib/admin-api", () => ({ isForbidden: () => forbidden })); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +let container: HTMLDivElement; +let root: Root; +let reload: ReturnType; +let fetchMock: ReturnType; + +/** `reloaded` is module state, so each test needs its own copy of the module. */ +async function render() { + const { RelaunchBanner } = await import("./RelaunchBanner"); + await act(async () => { + root.render(); + }); +} + +/** Let the 5s countdown elapse, which is what fires the relaunch. */ +async function runCountdown() { + for (let i = 0; i < 5; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + } +} + +function answers(body: Data) { + fetchMock.mockResolvedValue({ ok: true, json: async () => body }); +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.resetModules(); + vi.useFakeTimers(); + relaunch.mockClear(); + forbidden = false; + reload = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { reload }, + }); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("the tab that did ask", () => { + it("does not reload while the process it asked to restart is still answering", async () => { + data = { required: true }; + await render(); + + // SIGTERM only starts the shutdown. Through the whole drain and teardown + // the dying process answers 200 — and it is still the one that owes the + // relaunch, so it still says required. + answers({ required: true }); + await runCountdown(); + expect(relaunch).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(fetchMock).toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads once the successor is the one answering", async () => { + data = { required: true }; + await render(); + + answers({ required: true }); + await runCountdown(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(reload).not.toHaveBeenCalled(); + + answers({ required: false }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); + +describe("a seat that may not restart", () => { + it("says so once, and does not poll or reload after the refusal", async () => { + // Without this the 403 looked like any other dropped connection: poll, + // find the same server still answering, reload, count down, post again — + // a reload loop on a seat that can never end it. + forbidden = true; + relaunch.mockRejectedValueOnce(new Error("Forbidden")); + data = { required: true }; + await render(); + + answers({ required: true }); + await runCountdown(); + expect(relaunch).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + expect(container.textContent).toContain("cannot restart Condor"); + }); + + it("offers no Restart now button, which would be refused the same way", async () => { + forbidden = true; + relaunch.mockRejectedValueOnce(new Error("Forbidden")); + data = { required: true }; + await render(); + await runCountdown(); + + const labels = [...container.querySelectorAll("button")].map( + (b) => b.textContent, + ); + expect(labels).not.toContain("Restart now"); + }); +}); diff --git a/frontend/src/components/RelaunchBanner.tsx b/frontend/src/components/RelaunchBanner.tsx index 02fae749c..6343dc024 100644 --- a/frontend/src/components/RelaunchBanner.tsx +++ b/frontend/src/components/RelaunchBanner.tsx @@ -1,28 +1,151 @@ import { RotateCw } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useReloadAfterRelaunch } from "@/hooks/useRelaunch"; +import { isForbidden } from "@/lib/admin-api"; +import { updatesApi } from "@/lib/updates-api"; /** - * "You updated; the running Condor is still the old one." + * "You updated; apply it" — and then it applies it. * - * An update deliberately stops short of exec'ing the server — Condor is rarely - * the top of its own process tree, and re-execing races whatever started it - * into a second copy on the same port (`condor/updates/run.py`). What that - * trade buys is safety; what it costs is a window where the dashboard bundle in - * an open tab is older than the updated files on disk. This strip makes that - * window legible instead of just weird, so it rides above every page rather - * than living in Settings where only the person who ran the update would see it. + * This used to say "run `make restart`", on the reasoning that re-execing races + * whatever started Condor into a second copy on the same port. That is not what + * the code does. `request_restart()` raises SIGTERM rather than exec'ing, so + * `teardown()` runs and `main()` execs only once the loop is gone; the exec then + * replaces the process image in place, keeping the pid, the parent and the + * controlling terminal. Nothing exits, so there is no second copy, and the + * listening socket is non-inheritable (PEP 446) and released by the exec. + * Telegram has had a button for this all along. * - * There is no dismiss. The only thing that clears it is the relaunch, and the - * poll notices that on its own — a dismissed banner would just be a mismatch - * nobody is told about any more. + * So the browser does what a router does when its firmware updates: counts + * down, restarts, and comes back on its own. The countdown is cancellable + * because two admins may be watching and one of them may want to pick the + * moment. + * + * The reload at the end is not optional. The build swaps in a new bundle with + * new hashed asset names, so a browser holding the old index.html would be + * running a different build from the API answering it. + */ + +/** Long enough to read the sentence and hit Cancel; short enough not to nag. */ +const COUNTDOWN_SECONDS = 5; + +/** How hard to try before admitting the server is not coming back. */ +const RECONNECT_TIMEOUT_MS = 90_000; +const RECONNECT_INTERVAL_MS = 1_000; + +type Phase = "counting" | "restarting" | "cancelled" | "stuck" | "denied"; + +type Relaunch = { + from_commit?: string; + target_commit?: string; +}; + +/** + * Reload at most once per page. + * + * Two independent things can conclude the same relaunch — the fast poll the + * initiating tab runs, and the shared query every tab is already polling — and + * whichever arrives first is right. The loser must not then reload the page it + * just replaced. + */ +let reloaded = false; + +function reloadOnce() { + if (reloaded) return; + reloaded = true; + window.location.reload(); +} + +/** + * Gate only. The countdown lives in `RelaunchStrip`, which is mounted the + * moment a relaunch becomes required — so its opening phase is its *initial* + * state rather than something an effect has to set after the first render. + * + * `useReloadAfterRelaunch` is the plain query plus the reload every *other* + * tab depends on — the second admin's, one that pressed Cancel, one whose seat + * cannot restart at all, and all of them when the relaunch came from Telegram + * or a `make restart`. It is called here rather than in the strip because the + * transition it waits for is the one that stops the strip being rendered. */ export function RelaunchBanner() { const { data } = useReloadAfterRelaunch(); + if (!data?.required) return null; + return ; +} + +function RelaunchStrip({ data }: { data: Relaunch }) { + const [phase, setPhase] = useState("counting"); + const [left, setLeft] = useState(COUNTDOWN_SECONDS); const [showHow, setShowHow] = useState(false); - if (!data?.required) return null; + // Wait for the server to answer again, then reload once to pick up the new + // bundle. Polled rather than pushed: the socket is down for the whole window, + // and this needs to work before any of the app's own channels are back. + const waitForServer = useCallback(() => { + const deadline = Date.now() + RECONNECT_TIMEOUT_MS; + const tick = async () => { + try { + const res = await fetch("/api/v1/meta/relaunch", { cache: "no-store" }); + // Answering is not the signal. SIGTERM only *starts* the shutdown: + // uvicorn drains in-flight requests and `teardown()` flushes + // persistence, agent loops and MCP children, which routinely outlasts + // the first tick here. The dying process answers 200 for all of it, and + // reloading against it serves the new bundle — already swapped onto + // disk by the build step — from the old API. `required` is the flag + // that cannot lie: set once per process and never cleared, so only the + // successor answers false. + if (res.ok) { + const body = (await res.json().catch(() => null)) as { + required?: boolean; + } | null; + if (body?.required === false) { + reloadOnce(); + return; + } + } + } catch { + // Expected for most of the window — the server is not listening yet. + } + if (Date.now() > deadline) { + setPhase("stuck"); + return; + } + window.setTimeout(tick, RECONNECT_INTERVAL_MS); + }; + window.setTimeout(tick, RECONNECT_INTERVAL_MS); + }, []); + + const relaunchNow = useCallback(async () => { + setPhase("restarting"); + try { + await updatesApi.relaunch(); + } catch (err) { + // A dropped connection says nothing: the process handling this request + // tears down and execs, so the socket usually closes before the reply. + // A *403* is different — it means the restart was refused and will not + // happen. Polling then finds the same server still answering, reloads, + // and the banner starts counting again: a reload loop on a seat that + // can never end it. Say so and stop instead. + if (isForbidden(err)) { + setPhase("denied"); + return; + } + } + waitForServer(); + }, [waitForServer]); + + // One timer, and everything it does happens inside the callback. Firing the + // relaunch from the effect body instead would be a synchronous setState + // during an effect, which cascades renders. + useEffect(() => { + if (phase !== "counting") return; + const id = window.setTimeout(() => { + if (left <= 1) void relaunchNow(); + else setLeft(left - 1); + }, 1000); + return () => window.clearTimeout(id); + }, [phase, left, relaunchNow]); const moved = data.from_commit && data.target_commit @@ -31,39 +154,117 @@ export function RelaunchBanner() { return (
- +

- Condor has been updated.{" "} - - Relaunch it to apply the changes — it is still running the code it - booted with - {moved ? ( - <> - {" "} - ({moved}) - - ) : null} - . - + {phase === "counting" && ( + <> + Condor has been updated.{" "} + + Restarting in {left}s to apply it + {moved ? ( + <> + {" "} + ({moved}) + + ) : null} + . Bots and open positions are untouched. + + + )} + {phase === "restarting" && ( + <> + Restarting Condor…{" "} + + This page will reconnect and reload on its own. + + + )} + {phase === "stuck" && ( + <> + Condor has not come back.{" "} + + Check the terminal it was started from — if the restart failed, the + reason is in .condor/restart-failure.log. + + + )} + {phase === "denied" && ( + <> + + This seat cannot restart Condor. + {" "} + + The update is on disk, but applying it needs the admin role — the + request was refused. Give this user{" "} + role: admin in{" "} + config.yml, or run{" "} + make restart on the host. + + + )} + {phase === "cancelled" && ( + <> + Condor has been updated.{" "} + + It is still running the code it booted with + {moved ? ( + <> + {" "} + ({moved}) + + ) : null} + . + + + )}

- + {phase === "counting" && ( + + )} + + {/* + Not in `denied`. The button posts the same request to the same seat, + which will refuse it the same way — an offer that cannot be taken, under + a sentence explaining that it cannot. What that seat has to do instead + is in the message, and neither half of it is a button. + */} + {(phase === "cancelled" || phase === "stuck") && ( + + )} + + {phase === "cancelled" && ( + + )} - {showHow && ( + {showHow && phase === "cancelled" && (

- Run{" "} + Restart now restarts Condor in place. You can also run{" "} make restart {" "} - from the Condor directory, or stop and start it however you launched - it. Bots and open positions are untouched; continuous routines and - agent loops are restored on the way back up. + from the Condor directory. Bots and open positions are untouched; + continuous routines and agent loops are restored on the way back up.

)}
diff --git a/frontend/src/components/settings/UpdatesSettings.tsx b/frontend/src/components/settings/UpdatesSettings.tsx index 4ac8a090f..aed4a8607 100644 --- a/frontend/src/components/settings/UpdatesSettings.tsx +++ b/frontend/src/components/settings/UpdatesSettings.tsx @@ -14,6 +14,7 @@ import { useEffect, useState } from "react"; import { ConfirmDialog } from "@/components/agent/ConfirmDialog"; import { RELAUNCH_KEY, useRelaunch } from "@/hooks/useRelaunch"; +import { formatRelativeTime } from "@/lib/formatters"; import { type Block, type ComponentStatus, @@ -182,8 +183,18 @@ export function UpdatesSettings() { onClose={() => setConfirmDiscard(null)} >

- These files will be reset to the last commit. The changes cannot be - recovered — Stash keeps them instead. + Your changes to these files will be thrown away and the files reset + to the last commit. This cannot be undone.{" "} + {confirmDiscard?.resolutions.includes("keep-mine") ? ( + <> + Keep my version moves them somewhere updates + leave alone; Stash parks them in git. + + ) : ( + <> + Stash parks them in git instead. + + )}

    {confirmDiscard?.paths.map((p) =>
  • {p}
  • )} @@ -343,6 +354,19 @@ function ComponentCard({ } /** The plan, what it will cost, and anything standing in the way. */ +/** + * What each resolution is actually called on screen. + * + * "keep-mine" reads as jargon and is the one an operator most wants to find, so + * it says what it does. The others keep their plain names. + */ +const RESOLUTION_LABELS: Record = { + "keep-mine": "Keep my version", + discard: "Discard", + stash: "Stash", + cancel: "Cancel", +}; + function PreflightView({ preflight, isStarting, @@ -409,13 +433,16 @@ function PreflightView({ key={action} onClick={() => onResolve(block, action)} disabled={isResolving} - className={`rounded-md px-3 py-1.5 text-xs font-medium capitalize transition-colors disabled:opacity-50 ${ + className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 ${ action === "discard" ? "bg-[var(--color-red)]/15 text-[var(--color-red)] hover:opacity-80" - : "border border-[var(--color-border)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]" + : action === "keep-mine" + ? // The only non-lossy way out, so it leads. + "bg-[var(--color-green)]/15 text-[var(--color-green)] hover:opacity-80" + : "border border-[var(--color-border)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]" }`} > - {action} + {RESOLUTION_LABELS[action] ?? action} ))} @@ -491,7 +518,7 @@ function StepRow({ step }: { step: Step }) { {step.label} - {step.state === "failed" && step.output_tail && ( + {(step.state === "failed" || step.state === "warned") && step.output_tail && (
    Output @@ -512,6 +539,8 @@ function StepIcon({ state }: { state: Step["state"] }) { if (state === "ok") return ; if (state === "failed") return ; + if (state === "warned") + return ; if (state === "skipped") return ; return ; @@ -528,6 +557,7 @@ function FinishedView({ isDismissing: boolean; }) { const ok = run.state === "succeeded"; + const when = run.ended ?? run.started ?? null; // From the same query the shell's banner reads, so the two cannot disagree // about whether the relaunch has happened. const { data: relaunch } = useRelaunch(); @@ -550,28 +580,40 @@ function FinishedView({ {ok ? `Updated ${run.components.join(", ")}` : `Update of ${run.components.join(", ")} failed`} + {/* The journal outlives the process, so this panel can be showing a + run from last week — and without a time a week-old failure and a + two-minute-old one read identically. `ended` is the moment that + matters; `started` covers a run that never reached one. The exact + stamp is on hover rather than inline, because the question this + answers is "is this still relevant", not "when exactly". */} + {when !== null && ( + + {formatRelativeTime(when)} + + )} {run.error && (

    {run.error}

    )} - {/* The one step the engine deliberately leaves undone. Spelled out here - rather than left to the banner alone: this is the screen the person - who just pressed Update is looking at. */} + {/* The banner above handles the restart itself, including the countdown + and the cancel. This says the same thing on the screen the person who + just pressed Update is actually looking at, without a second set of + controls competing with it. */} {relaunch?.required && (
    Condor is still running{" "} - {relaunch.from_commit}. Relaunch - it —{" "} - - make restart - {" "} - — to come up on{" "} - {relaunch.target_commit}. Bots and - open positions are untouched. + {relaunch.from_commit}. It is + restarting to come up on{" "} + {relaunch.target_commit} — see the + banner at the top of the page, which counts down and can be + cancelled. Bots and open positions are untouched.
    )} diff --git a/frontend/src/lib/updates-api.ts b/frontend/src/lib/updates-api.ts index 5cdf1c432..a89fa0760 100644 --- a/frontend/src/lib/updates-api.ts +++ b/frontend/src/lib/updates-api.ts @@ -36,7 +36,8 @@ export interface ComponentStatus { key: string; name: string; facets: Partial>; - mode: "image" | "source" | null; + /** Always "image": hummingbot-api is deployed from the published image. */ + mode: "image" | null; up_to_date: boolean; } @@ -66,7 +67,14 @@ export interface Preflight { ok: boolean; } -export type StepState = "pending" | "running" | "ok" | "failed" | "skipped"; +export type StepState = + | "pending" + | "running" + | "ok" + /** Ran, did not fail the update, but the output is worth reading. */ + | "warned" + | "failed" + | "skipped"; export interface Step { key: string; @@ -147,6 +155,20 @@ export const updatesApi = { body: JSON.stringify({ run_id: runId }), }), + /** + * Apply a finished update by restarting Condor in place. + * + * The request is expected not to answer: the process handling it tears down + * and execs. 202 if it does answer, a network error if the socket closes + * first — both mean the same thing, so callers treat a failure here as + * "probably restarting" and let the reconnect decide. + */ + relaunch: () => + adminFetch<{ relaunching: boolean; target_commit: string }>( + "/api/v1/updates/relaunch", + { method: "POST" }, + ), + /** Trigger the update. Answers immediately with the run to watch. */ start: (components: string[]) => adminFetch<{ run_id: string; state: RunState }>("/api/v1/updates/start", { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index fbbc3cabd..6147241bc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -111,9 +111,19 @@ export function Settings() { const groups = buildGroups(server, isAdmin); const tabs = groups.flatMap((g) => g.tabs); const requested = (params.get("tab") as TabKey) || "servers"; + const available = tabs.some((t) => t.key === requested); // A deep link to ?tab=admin or ?tab=updates from a seat that is not (or no // longer) an admin falls back rather than rendering an empty page. - const tab = tabs.some((t) => t.key === requested) ? requested : "servers"; + const tab = available ? requested : "servers"; + // ...but silently is the wrong way to fall back when the product itself + // sent the link. The "Updates available" notice links to ?tab=updates, so a + // non-admin seat clicks it, lands on Servers, and sees nothing happen at + // all — the tab is gone, the notice is still there, and nothing anywhere + // says why. Say why. + const deniedTab = + !available && (requested === "updates" || requested === "admin") + ? requested + : null; return (
    @@ -207,8 +217,36 @@ export function Settings() { )} {tab === "admin" && } {tab === "updates" && } + {deniedTab && }
    ); } + +/** Why a tab the reader was linked to is not on their screen. + * + * Reachable the ordinary way: the hourly update check posts an "Updates + * available" notification whose link is `?tab=updates`, and it goes to + * `ADMIN_USER_ID` — which in local mode is the id the dashboard signs in as, + * whether or not `config.yml` gives that id the admin role. When it does not, + * the click used to do nothing observable. + */ +function AdminOnlyNotice({ tab }: { tab: "updates" | "admin" }) { + const what = tab === "updates" ? "Updates" : "Admin"; + return ( +
    +

    + {what} is admin-only +

    +

    + This seat is signed in without the admin role, so the {what} tab is not + shown and its API answers 403. Give this user{" "} + role: admin in{" "} + config.yml, then + reload. make doctor{" "} + names the id it signs in as. +

    +
    + ); +} diff --git a/handlers/admin/update.py b/handlers/admin/update.py index 234911e32..2ce85bbd8 100644 --- a/handlers/admin/update.py +++ b/handlers/admin/update.py @@ -33,6 +33,8 @@ "ok": "✓", "failed": "✗", "skipped": "–", + # Ran, did not fail the update, but the output is worth reading. + "warned": "!", } @@ -329,10 +331,15 @@ def _run_keyboard(run) -> InlineKeyboardMarkup | None: if run.live: return None rows = [] - # Offered, never taken automatically: an in-process restart is an ``execv`` - # that can race whatever started Condor into a second copy of it, so it is - # only ever something the admin chooses (see :mod:`condor.updates.run`). - if run.state == "failed" or updates.relaunch_pending() is not None: + # Offered, never taken automatically. Not because restarting is unsafe -- + # ``request_restart`` signals rather than exec's, so teardown runs and the + # exec replaces this process in place -- but because the moment belongs to + # whoever is watching the bots (see :mod:`condor.updates.run`). + # Only when there is something to apply. The failed-run arm used to be here + # too, which put a Restart Now button directly beneath the engine's own + # "dependencies failed -- fix it before restarting": pressing it booted the + # new code against the old venv. + if updates.relaunch_pending() is not None: rows.append( [InlineKeyboardButton("Restart Now", callback_data="admin:update_restart")] ) diff --git a/main.py b/main.py index 0b2d3b1f8..4f06770f3 100644 --- a/main.py +++ b/main.py @@ -1255,6 +1255,14 @@ def _signal_handler(): for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, _signal_handler) + # The boot got this far, so a later crash should take the tmux session with + # it again -- otherwise `make status` would report a dead Condor as running. + # exec_restart() turned this on so that a failed handover stayed readable; + # this is the other half, mirroring what the Makefile does after its probe. + from utils.updater import set_tmux_remain_on_exit + + set_tmux_remain_on_exit(False) + # Run uvicorn as a task web_task = asyncio.create_task(server.serve()) stop_task = asyncio.create_task(shutdown_event.wait()) diff --git a/pyproject.toml b/pyproject.toml index e3577e49a..8dfd02ef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "condor" version = "0.1.0" description = "Telegram bot for Hummingbot trading via Backend API" -requires-python = ">=3.12" +requires-python = ">=3.12,<3.14" dependencies = [ "python-telegram-bot[job-queue]", "hummingbot-api-client==1.5.9", diff --git a/routines/base.py b/routines/base.py index 4900dc939..2f37565cc 100644 --- a/routines/base.py +++ b/routines/base.py @@ -22,6 +22,7 @@ agent_home_layers, shared_routines_roots, ) +from condor.paths import local_agents_root logger = logging.getLogger(__name__) @@ -41,17 +42,24 @@ def library_dir() -> Path: def assistant_routines_dir(agent_slug: str | None) -> Path: """The **writable** routines dir of an assistant — the one it owns. - There is a single home for the general library — the repo-root ``routines/``, - owned by the chat ``condor``. A trading agent / domain expert owns - ``//routines``. + Every assistant writes under the local agent root, the chat included: - - chat ``condor`` (``agent_slug`` None **or** ``"condor"``) → ``routines`` + - chat ``condor`` (``agent_slug`` None **or** ``"condor"``) → + ``/condor/routines`` - trading agent / domain expert (slug) → ``//routines`` - The explicit ``"condor"`` carve-out is load-bearing (FEAT-033): Condor is now - an ordinary entry under the agent roots, so threading its slug through - naively would relocate the general library to ``/routines`` and empty - the catalog — silently, since a missing dir simply lists nothing. + The chat used to write into the repo-root ``routines/`` instead, which is + tracked and actively maintained upstream (13 files, 48 commits touching them + over 60) — so asking the chat agent to improve a shipped routine edited a + file upstream also edits, and the next update that touched it raised a + dirty-conflict whose escapes were lossy. + + The explicit ``"condor"`` carve-out that remains is still load-bearing + (FEAT-033): Condor is an ordinary entry under the agent roots, so threading + its slug through naively would relocate the general *library* as well and + empty the catalog — silently, since a missing dir simply lists nothing. That + is why only the write target moves here, and the shipped root stays as the + read fallback in :func:`assistant_routines_dirs`. This is what an assistant may **write**, not everything it may run: since FEAT-038 every assistant also *reads* the shared library, and since FEAT-115 @@ -63,18 +71,21 @@ def assistant_routines_dir(agent_slug: str | None) -> Path: """ if agent_slug and agent_slug != CHAT_SLUG: return agent_home_layers(agent_slug)[0] / "routines" - return _PROJECT_ROOT / "routines" + return local_agents_root() / CHAT_SLUG / "routines" def assistant_routines_dirs(agent_slug: str | None) -> tuple[Path, ...]: """An assistant's own routine dirs in **read** order: local, then stock. - The chat's general library is the repo-root ``routines/`` and has no second - layer — it is not under either agent root. + The chat now has two layers like every other assistant: a writable local one + under the agent root, and the shipped ``routines/`` as the read fallback. The + fallback is what keeps FEAT-033's carve-out satisfied — the general library + stays fully visible, which is the thing that would break silently if the + slug were simply threaded through. """ if agent_slug and agent_slug != CHAT_SLUG: return tuple(home / "routines" for home in agent_home_layers(agent_slug)) - return (_PROJECT_ROOT / "routines",) + return (local_agents_root() / CHAT_SLUG / "routines", _PROJECT_ROOT / "routines") @dataclass @@ -302,6 +313,11 @@ def check_config_defaults(config_class: type[BaseModel]) -> None: _routines_cache: dict[str, "RoutineInfo"] | None = None +#: The root library alone, before the shared and local layers merge over it. +#: Kept apart because the mtime shortcut below restores a *root* file by +#: stem, and the merged dict may hold a layer that shadowed that stem -- +#: restoring which resurrects an override after its file is gone. +_root_routines_cache: dict[str, "RoutineInfo"] | None = None # {stem: mtime} of every file seen on the last scan of routines/ — including # files that failed to load, so a broken routine isn't re-executed on every @@ -446,10 +462,15 @@ def discover_routines(force_reload: bool = False) -> dict[str, RoutineInfo]: Returns: Dict mapping routine name to RoutineInfo """ - global _routines_cache + global _routines_cache, _root_routines_cache - fresh_start = force_reload or _routines_cache is None - prev_routines = {} if fresh_start else _routines_cache + # Either being cleared means "rescan": `_routines_cache = None` is the + # invalidation every existing caller already uses, and it must keep + # working now that the root layer has a cache of its own. + fresh_start = ( + force_reload or _routines_cache is None or _root_routines_cache is None + ) + prev_routines = {} if fresh_start else _root_routines_cache prev_mtimes = {} if fresh_start else _routines_mtimes routines_dir = library_dir() @@ -521,10 +542,24 @@ def discover_routines(force_reload: bool = False) -> dict[str, RoutineInfo]: # within the shared library the local root shadows the shipped one (the same # rule again, on the second axis). Each root keeps its own mtime cache in # ``_path_caches``, so hot-reload behaves identically for every half. - routines = {**shared, **routines} + # + # ``/condor/routines`` shadows both. It is where FEAT-115 moved the + # chat's *writes*, so it has to be part of the general library rather than + # bolted onto the catalog: `get_routine` -- and through it + # `RoutineStore._resolve_routine`, which is what actually starts a run -- + # reads this function. Adding the layer only to `assistant_routines` made + # an authored routine listable but not runnable, and made a local override + # of a shipped name run the shipped code. + own = _merged_from((assistant_routines_dir(CHAT_SLUG),), force_reload=force_reload) + # Held separately from the merge: the mtime shortcut above restores a root + # file by stem, and `routines` after this line may hold a layer that + # shadowed that stem. + root_only = routines + routines = {**shared, **root_only, **own} _routines_mtimes.clear() _routines_mtimes.update(scanned_mtimes) + _root_routines_cache = root_only _routines_cache = routines return routines @@ -681,6 +716,9 @@ def assistant_routines( is told, never about what a person may do. """ if not agent_slug or agent_slug == CHAT_SLUG: + # ``discover_routines`` now includes the chat's own local layer, so + # this is the general library again and every reader of it -- the + # catalog here, `get_routine`, the resolver behind a run -- agrees. found = discover_routines(force_reload=force_reload) else: shared = _merged_from(shared_routines_roots(), force_reload=force_reload) diff --git a/tests/test_agents_layering.py b/tests/test_agents_layering.py index 80e805736..aab9ee6da 100644 --- a/tests/test_agents_layering.py +++ b/tests/test_agents_layering.py @@ -841,3 +841,48 @@ def test_safe_slug_accepts_every_slug_slugify_produces(): safe_slug(bad) with pytest.raises(UnsafeIdError): resolve_agent_file("scout", "skills", "../../other", "SKILL.md") + + +# ── 4. The fork stamp survives the write that follows the fork ── + + +def test_a_dashboard_style_write_keeps_the_fork_stamp(stock): + """The web routes' write must not erase the stamp ``fork_path`` just wrote. + + ``fork_if_stock`` stamps the copy with the digest of the stock file it came + from, and the staleness check has nothing to compare without it. The routes + write a body the browser round-tripped from a GET of the **stock** file, + which carries no stamp — so a plain ``atomic_write_text`` erased it on the + very first save, and editing through the dashboard is how these files are + normally edited. + """ + from condor.frontmatter import parse_frontmatter + from condor.layering import fork_if_stock, write_preserving_stamp + + target = fork_if_stock("scout", "AGENT.md") + stamped, _ = parse_frontmatter(target.read_text("utf-8")) + assert stamped is not None and FORKED_FROM_KEY in stamped + original = stamped[FORKED_FROM_KEY] + + # Exactly what the route hands down: stock's body, with no stamp on it. + write_preserving_stamp( + target, AGENT_MD.format(name="Scout", desc="shipped", body="My edit.") + ) + + after, body = parse_frontmatter(target.read_text("utf-8")) + assert after is not None, "the write destroyed the frontmatter" + assert after[FORKED_FROM_KEY] == original, "the write destroyed the stamp" + assert "My edit." in body + # And the shipped file is still the shipped file. + assert "Ship." in (stock / "AGENT.md").read_text("utf-8") + + +def test_the_digest_ignores_line_endings(tmp_path): + """A stamp has to mean the same thing on a CRLF checkout as on an LF one.""" + from condor.layering import content_digest + + lf = tmp_path / "lf.md" + crlf = tmp_path / "crlf.md" + lf.write_bytes(b"---\nname: s\n---\n\n# S\nRule.\n") + crlf.write_bytes(b"---\r\nname: s\r\n---\r\n\r\n# S\r\nRule.\r\n") + assert content_digest(lf) == content_digest(crlf) diff --git a/tests/test_chat_routines_layering.py b/tests/test_chat_routines_layering.py new file mode 100644 index 000000000..0806f0d98 --- /dev/null +++ b/tests/test_chat_routines_layering.py @@ -0,0 +1,374 @@ +"""The chat agent stops writing into the tracked library (C6). + +Every other assistant's authored content lands in the gitignored local root. +The chat's did not: its writable routines dir was the repo-root ``routines/``, +which git tracks and upstream actively maintains — 13 files, 48 commits touching +them over 60. So asking the chat agent to improve a shipped routine modified a +file upstream also modifies, and the next update touching it raised a +dirty-conflict whose escapes were lossy. + +The carve-out that remains is load-bearing and these tests say so: FEAT-033 +found that threading the slug through naively relocates the general *library* +and empties the catalog, silently, because a missing directory simply lists +nothing. Only the write target moves; the shipped root stays as the read +fallback. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor import migrations, paths +from condor.memory.paths import CHAT_SLUG +from routines.base import assistant_routines_dir, assistant_routines_dirs + +# ── where writes land ── + + +@pytest.mark.parametrize("slug", [None, CHAT_SLUG]) +def test_the_chat_writes_into_the_local_root(slug): + """The whole point: `create_routine` can no longer touch the tracked tree.""" + target = assistant_routines_dir(slug) + assert target == paths.local_agents_root() / CHAT_SLUG / "routines" + # The thing that matters: it is not under the tracked tree any more. + assert paths.stock_agents_root() not in target.parents + assert not target.is_relative_to(paths._PROJECT_ROOT / "routines") + + +def test_a_specialist_is_unchanged(): + target = assistant_routines_dir("hyperliquid_expert") + assert target == paths.local_agents_root() / "hyperliquid_expert" / "routines" + + +# ── and the catalog stays populated (the FEAT-033 regression) ── + + +@pytest.mark.parametrize("slug", [None, CHAT_SLUG]) +def test_the_shipped_library_is_still_read(slug): + """If this ever regresses, the catalog empties silently. Fail loudly instead.""" + dirs = assistant_routines_dirs(slug) + assert len(dirs) == 2, "the chat lost a layer" + assert dirs[0] == paths.local_agents_root() / CHAT_SLUG / "routines" + + shipped = dirs[1] + assert shipped.name == "routines" + assert shipped.is_dir(), "the shipped library is not where reads look" + # The thing FEAT-033 broke: a populated catalog. + assert list(shipped.glob("*.py")), "the shipped routine catalog reads as empty" + + +def test_local_shadows_shipped_not_the_other_way_round(): + dirs = assistant_routines_dirs(None) + assert dirs.index(paths.local_agents_root() / CHAT_SLUG / "routines") == 0 + + +# ── existing chat-authored routines come along ── + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """A checkout with one shipped routine committed and one authored by the chat.""" + monkeypatch.setattr(paths, "_PROJECT_ROOT", tmp_path) + monkeypatch.setattr(migrations.paths, "_PROJECT_ROOT", tmp_path) + monkeypatch.setenv("CONDOR_RUNTIME_ROOT", str(tmp_path / ".condor")) + + library = tmp_path / "routines" + library.mkdir() + (library / "shipped.py").write_text("# upstream maintains this\n") + env = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", + } + for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-qm", "v1"]): + subprocess.run( + ["git", *args], cwd=tmp_path, check=True, capture_output=True, env=env + ) + # What the chat agent wrote before this change: untracked, in the tracked dir. + (library / "my_scanner.py").write_text("# written by the chat agent\n") + return tmp_path + + +def test_a_chat_authored_routine_is_lifted_into_the_local_root(repo): + report = migrations.MigrationReport() + migrations._lift_chat_routines(report) + + assert report.chat_routines == 1 + lifted = paths.local_agents_root() / CHAT_SLUG / "routines" / "my_scanner.py" + assert lifted.read_text() == "# written by the chat agent\n" + assert not (repo / "routines" / "my_scanner.py").exists() + + +def test_a_tracked_routine_is_never_moved(repo): + """Moving it would take the operator's edit out of the tree being updated. + + A tracked file is either shipped or a modification of something shipped, and + ``keep-mine`` exists for exactly that case — it is the operator's choice, + not the migration's. + """ + (repo / "routines" / "shipped.py").write_text("# upstream, plus my edit\n") + + report = migrations.MigrationReport() + migrations._lift_chat_routines(report) + + # The untracked one moved; the tracked one stayed exactly where it was. + assert report.chat_routines == 1 + assert ( + repo / "routines" / "shipped.py" + ).read_text() == "# upstream, plus my edit\n" + assert not ( + paths.local_agents_root() / CHAT_SLUG / "routines" / "shipped.py" + ).exists() + + +def test_the_lift_does_not_overwrite_something_already_there(repo): + destination = paths.local_agents_root() / CHAT_SLUG / "routines" + destination.mkdir(parents=True) + (destination / "my_scanner.py").write_text("# the newer local one\n") + + report = migrations.MigrationReport() + migrations._lift_chat_routines(report) + + assert report.chat_routines == 0 + assert (destination / "my_scanner.py").read_text() == "# the newer local one\n" + + +def test_the_lift_is_idempotent(repo): + first = migrations.MigrationReport() + migrations._lift_chat_routines(first) + second = migrations.MigrationReport() + migrations._lift_chat_routines(second) + + assert first.chat_routines == 1 + assert second.chat_routines == 0 + + +# ── The chat's local layer is reported when upstream moves under it ── + + +def _chat_layers(tmp_path=None): + from routines.base import assistant_routines_dirs + + return assistant_routines_dirs(None) + + +def test_an_improved_shipped_routine_is_reported_before_and_after(monkeypatch): + """The scenario the local layer exists to create, and nothing reported it. + + FEAT-033 keeps the shipped library at the repo-root ``routines/`` and only + the write target moved under the agent — so the generic scan looked for + stock at ``/condor/routines``, found nothing, and filed the file as + something the agent authored from scratch. + """ + from condor.layering import all_stale_forks, locally_overridden + + local_dir, stock_dir = _chat_layers() + shipped = stock_dir / "arb_check.py" + original = shipped.read_bytes() if shipped.is_file() else None + try: + shipped.parent.mkdir(parents=True, exist_ok=True) + shipped.write_text("# shipped\n", "utf-8") + local_dir.mkdir(parents=True, exist_ok=True) + (local_dir / "arb_check.py").write_text( + "# shipped\n# my improvement\n", "utf-8" + ) + + # Before: the update is about to rewrite a file we have our own copy of. + assert locally_overridden(["routines/arb_check.py"]) == [ + "routines/arb_check.py" + ] + + # After: upstream's rewrite has landed and ours still wins. + shipped.write_text("# UPSTREAM rewrote this\n", "utf-8") + stale = [f for f in all_stale_forks() if f.rel == "routines/arb_check.py"] + assert len(stale) == 1, "the chat's shadow of a shipped routine went unreported" + assert stale[0].label == "condor/routines/arb_check.py" + assert stale[0].unprovenanced is True, ".py can never carry a stamp" + finally: + if original is None: + shipped.unlink(missing_ok=True) + else: + shipped.write_bytes(original) + + +def test_a_routine_the_chat_invented_is_not_reported(): + """No shipped counterpart means it was never a fork of anything.""" + from condor.layering import all_stale_forks + + local_dir, _ = _chat_layers() + local_dir.mkdir(parents=True, exist_ok=True) + (local_dir / "my_own_idea.py").write_text("# mine alone\n", "utf-8") + + assert [f for f in all_stale_forks() if f.rel.endswith("my_own_idea.py")] == [] + + +def test_a_routine_the_chat_authors_is_discoverable(monkeypatch, tmp_path): + """FEAT-115 moved the chat's *write* target; discovery did not follow. + + `assistant_routines` sends the chat through `discover_routines`, which + reads the root library and the shared one — not `/condor/routines`, + where the chat now writes. So a routine the agent authored never reached + the catalog, and the migration that lifts previously untracked ones out of + `routines/` made those vanish from it too. + """ + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + + from routines.base import assistant_routines, assistant_routines_dir + + target = assistant_routines_dir(None) + target.mkdir(parents=True, exist_ok=True) + (target / "chat_authored.py").write_text( + "from pydantic import BaseModel\n" + "class Config(BaseModel):\n" + ' """One the chat wrote"""\n' + "async def run(config, context):\n" + ' return "ok"\n', + encoding="utf-8", + ) + + found = assistant_routines(None, force_reload=True) + assert "chat_authored" in found, sorted(found) + + +def test_the_shipped_library_is_still_there_beside_it(monkeypatch, tmp_path): + """FEAT-033's carve-out: the general catalog must not shrink.""" + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + + from routines.base import assistant_routines + + found = assistant_routines(None, force_reload=True) + assert len(found) > 5, f"the general library disappeared: {sorted(found)}" + + +def test_a_chat_authored_routine_keeps_the_general_librarys_source( + monkeypatch, tmp_path +): + """`agent_slug` stamps `source="agent:"`, and that is load-bearing. + + The MCP tool renames an `agent:` routine to `/` and labels it + agent-scoped with the specialist slug — empty for the chat. The chat's + local layer is the writable half of the *general* library, so its + routines carry the same bare name and `global` source as the rest of it. + """ + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + + from routines.base import assistant_routines, assistant_routines_dir + + target = assistant_routines_dir(None) + target.mkdir(parents=True, exist_ok=True) + (target / "authored.py").write_text( + "from pydantic import BaseModel\n" + "class Config(BaseModel):\n" + ' """mine"""\n' + "async def run(config, context):\n" + ' return "ok"\n', + encoding="utf-8", + ) + + found = assistant_routines(None, force_reload=True) + assert found["authored"].source == "global", found["authored"].source + + +def test_an_authored_routine_can_actually_be_run(monkeypatch, tmp_path): + """Listable is not runnable, and the catalog was only half the story. + + `RoutineStore._resolve_routine` starts a run through `get_routine`, which + reads `discover_routines`. Adding the chat's local layer to + `assistant_routines` alone put the routine in the catalog and left the + resolver unable to find it, so starting it failed. + """ + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + + from routines.base import assistant_routines, assistant_routines_dir, get_routine + + target = assistant_routines_dir(None) + target.mkdir(parents=True, exist_ok=True) + (target / "brand_new.py").write_text( + "from pydantic import BaseModel\n" + "class Config(BaseModel):\n" + ' """LOCAL-ONLY"""\n' + "async def run(config, context):\n" + ' return "ok"\n', + encoding="utf-8", + ) + + assert "brand_new" in assistant_routines(None, force_reload=True) + resolved = get_routine("brand_new") + assert resolved is not None, "in the catalog but the resolver cannot find it" + assert resolved.description == "LOCAL-ONLY" + + +def test_a_local_override_runs_instead_of_the_shipped_routine(monkeypatch, tmp_path): + """Shadowing that the catalog shows but the resolver ignores is worse than + no shadowing: the operator reads one description and runs other code.""" + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + + from routines.base import ( + assistant_routines, + assistant_routines_dir, + get_routine, + library_dir, + ) + + shipped = sorted( + p.stem for p in library_dir().glob("*.py") if p.stem not in ("__init__", "base") + )[0] + target = assistant_routines_dir(None) + target.mkdir(parents=True, exist_ok=True) + (target / f"{shipped}.py").write_text( + "from pydantic import BaseModel\n" + "class Config(BaseModel):\n" + ' """LOCAL-OVERRIDE"""\n' + "async def run(config, context):\n" + ' return "ok"\n', + encoding="utf-8", + ) + + catalog = assistant_routines(None, force_reload=True) + assert catalog[shipped].description == "LOCAL-OVERRIDE" + assert ( + get_routine(shipped).description == "LOCAL-OVERRIDE" + ), "the catalog shows the override while the resolver runs the shipped one" + + +def test_a_removed_local_override_stops_shadowing(monkeypatch, tmp_path): + """The mtime shortcut restores a root file by stem from the cache. + + Once the chat's local layer can shadow a root stem, that cached entry may + be the override — so reusing it resurrects a local routine after its file + is gone, and the shipped one stays unreachable. + """ + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + + from routines import base + from routines.base import assistant_routines_dir, get_routine, library_dir + + monkeypatch.setattr(base, "_routines_cache", None) + monkeypatch.setattr(base, "_root_routines_cache", None) + + shipped = sorted( + p.stem for p in library_dir().glob("*.py") if p.stem not in ("__init__", "base") + )[0] + shipped_desc = get_routine(shipped).description + + target = assistant_routines_dir(None) + target.mkdir(parents=True, exist_ok=True) + override = target / f"{shipped}.py" + override.write_text( + "from pydantic import BaseModel\n" + "class Config(BaseModel):\n" + ' """LOCAL-OVERRIDE"""\n' + "async def run(config, context):\n" + ' return "ok"\n', + encoding="utf-8", + ) + assert get_routine(shipped).description == "LOCAL-OVERRIDE" + + override.unlink() + assert ( + get_routine(shipped).description == shipped_desc + ), "the deleted override is still being served from cache" diff --git a/tests/test_condor_is_an_agent.py b/tests/test_condor_is_an_agent.py index 160a4187b..89d6ffe21 100644 --- a/tests/test_condor_is_an_agent.py +++ b/tests/test_condor_is_an_agent.py @@ -44,20 +44,30 @@ def registry(tmp_path, monkeypatch): # ── Carve-out 1: the chat's routine library does not move ── -def test_chat_routines_dir_is_the_repo_root_library(): - """A falsy slug AND ``"condor"`` both resolve the general library. +def test_the_chat_still_reads_the_repo_root_library(): + """A falsy slug AND ``"condor"`` both still *read* the general library. Condor is an ordinary ``agents/`` entry now, so the naive ``agents//routines`` rule would point the chat at ``agents/condor/routines`` — a dir that does not exist, so every routine - would vanish from the catalog without a single error. + would vanish from the catalog without a single error. That is what this + carve-out protects, and it is about **reads**. + + The chat's *write* target did move, to the gitignored local root: writing + into the repo-root library meant the chat agent edited files upstream also + maintains, which the next update then conflicted on. The shipped root + remains the read fallback, so the catalog is as full as it ever was. """ - from routines.base import assistant_routines_dir + from routines.base import assistant_routines_dir, assistant_routines_dirs general = _REPO_ROOT / "routines" - assert assistant_routines_dir(None) == general - assert assistant_routines_dir("") == general - assert assistant_routines_dir(CHAT_SLUG) == general + for slug in (None, "", CHAT_SLUG): + assert general in assistant_routines_dirs(slug), "the catalog would empty" + # And writes land somewhere git has never heard of. + assert assistant_routines_dir(slug) != general + + # The guarantee in the terms that actually matter: routines to list. + assert list(general.glob("*.py")), "the shipped catalog reads as empty" def test_a_specialist_still_owns_an_isolated_routines_dir(): diff --git a/tests/test_conflict_resolutions.py b/tests/test_conflict_resolutions.py new file mode 100644 index 000000000..6ccfc407b --- /dev/null +++ b/tests/test_conflict_resolutions.py @@ -0,0 +1,471 @@ +"""A hand-edited playbook no longer has to be destroyed to update (C12, S2). + +FEAT-115 redirects writes made *through the product* into the gitignored local +root. It does nothing about a text editor — and these are markdown files sitting +in a git checkout, which is exactly what people open in one. Such an edit blocks +the update with a `dirty-conflict`, and both escapes lost something: `discard` +destroyed the work outright (while reporting it as a *restoration*), and `stash` +parked it behind a shell command the Local user was never asked to open. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor.updates import components +from utils import updater + +ENV = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", +} + + +def _git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, env=ENV) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + d = tmp_path / "repo" + (d / "agents" / "scout").mkdir(parents=True) + (d / "agents" / "scout" / "AGENT.md").write_text("# Scout v1\nNever on Sundays.\n") + _git("init", "-q", "-b", "main", cwd=d) + _git("add", "-A", cwd=d) + _git("commit", "-qm", "v1", cwd=d) + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + return d + + +# ── which conflicts can be kept ── + + +def test_only_agent_content_can_be_kept(): + keep = components.is_forkable + assert keep(components.CONDOR, "agents/scout/AGENT.md") is True + assert keep(components.CONDOR, "agents/scout/skills/recon/SKILL.md") is True + # No local layer, so "keeping" it would be a quieter way of losing it. + assert keep(components.CONDOR, "main.py") is False + assert keep(components.CONDOR, "agents/_shared/skills/x/SKILL.md") is False + assert keep(components.HUMMINGBOT_API, "agents/scout/AGENT.md") is False + + +def test_keep_mine_is_offered_only_when_every_path_can_take_it(): + """A mixed set would read as "keep all of this" and quietly reset the rest.""" + all_agents = components._resolutions_for( + components.CONDOR, ["agents/scout/AGENT.md", "agents/keeper/AGENT.md"] + ) + assert all_agents[0] == "keep-mine" + + mixed = components._resolutions_for( + components.CONDOR, ["agents/scout/AGENT.md", "main.py"] + ) + assert "keep-mine" not in mixed + assert mixed == ["discard", "stash", "cancel"] + + +# ── keeping it ── + + +@pytest.mark.asyncio +async def test_keep_mine_preserves_the_edit_and_frees_the_update(repo, tmp_path): + edited = repo / "agents" / "scout" / "AGENT.md" + edited.write_text("# Scout v1 + TWO HOURS OF MY WORK\n") + + ok, message = await updater.move_to_local_root(str(repo), ["agents/scout/AGENT.md"]) + assert ok, message + + # The work survives, where the product would have put it. + kept = tmp_path / "local" / "scout" / "AGENT.md" + assert kept.read_text() == "# Scout v1 + TWO HOURS OF MY WORK\n" + + # The tracked file is back to HEAD, so the fast-forward is unobstructed. + assert "TWO HOURS" not in edited.read_text() + status = subprocess.run( + ["git", "status", "--short"], cwd=repo, capture_output=True, text=True + ) + assert status.stdout.strip() == "" + + +@pytest.mark.asyncio +async def test_the_discard_message_says_what_was_lost(repo): + """It used to report a destructive act as "Restored 1 tracked file(s)".""" + (repo / "agents" / "scout" / "AGENT.md").write_text("my work\n") + + ok, message = await updater.discard_paths(str(repo), ["agents/scout/AGENT.md"]) + assert ok + assert "Discarded" in message + assert "Restored" not in message + + +# ── and getting stashed work back ── + + +@pytest.mark.asyncio +async def test_a_clean_stash_pops_itself(repo): + (repo / "agents" / "scout" / "AGENT.md").write_text("my work\n") + ok, _ = await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + assert ok + assert "my work" not in (repo / "agents" / "scout" / "AGENT.md").read_text() + + ok, message = await updater.stash_pop(str(repo)) + assert ok, message + assert (repo / "agents" / "scout" / "AGENT.md").read_text() == "my work\n" + + +@pytest.mark.asyncio +async def test_popping_nothing_is_not_an_error(repo): + ok, message = await updater.stash_pop(str(repo)) + assert ok + assert "Nothing of ours" in message + + +@pytest.mark.asyncio +async def test_a_stash_we_did_not_make_is_left_alone(repo): + """Someone else's stash is not ours to pop.""" + (repo / "agents" / "scout" / "AGENT.md").write_text("their work\n") + subprocess.run( + ["git", "stash", "push", "-m", "someone else's"], + cwd=repo, + check=True, + capture_output=True, + env=ENV, + ) + + ok, message = await updater.stash_pop(str(repo)) + assert ok + assert "Nothing of ours" in message + # Still there, untouched. + listing = subprocess.run( + ["git", "stash", "list"], cwd=repo, capture_output=True, text=True + ) + assert "someone else's" in listing.stdout + + +# ── S2: a stash that cannot be replayed must leave the tree usable ── + + +@pytest.fixture +def stashed(repo, tmp_path): + """The main line of the `stash` resolution, set up exactly as it happens. + + `stash` is offered as a way out of `dirty-conflict`, and that block means + the incoming commits touch the file being parked — so the replay conflicts + in the ordinary case, not a rare one. + """ + agent = repo / "agents" / "scout" / "AGENT.md" + origin = tmp_path / "origin.git" + _git("clone", "-q", "--bare", str(repo), str(origin), cwd=tmp_path) + _git("remote", "add", "origin", str(origin), cwd=repo) + _git("fetch", "-q", "origin", cwd=repo) + _git("branch", "-q", "--set-upstream-to=origin/main", "main", cwd=repo) + + # Upstream rewrites the same file. + work = tmp_path / "work" + _git("clone", "-q", str(origin), str(work), cwd=tmp_path) + (work / "agents" / "scout" / "AGENT.md").write_text("# Scout v2\nUPSTREAM rule.\n") + _git("commit", "-aqm", "v2", cwd=work) + _git("push", "-q", "origin", "main", cwd=work) + + # The operator's own edit, and unrelated uncommitted work elsewhere — the + # runtime state these checkouts are always in. + agent.write_text("# Scout v1\nNever on Sundays.\nMY EDIT.\n") + bystander = repo / "runtime-notes.txt" + bystander.write_text("do not lose me\n") + return repo, agent, bystander + + +@pytest.mark.asyncio +async def test_a_stash_that_cannot_be_replayed_leaves_no_conflict_markers(stashed): + """`git stash pop` wrote the markers into the file and kept the stash. + + The update then reported success over a checkout holding a half-merged + agent playbook, an unmerged index, and advice — "resolve it by hand with + git stash pop" — that errors with "needs merge" when followed. + """ + repo, agent, bystander = stashed + + ok, _ = await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + assert ok + ok, _ = await updater.fast_forward(str(repo)) + assert ok + + restored, message = await updater.stash_pop(str(repo)) + + assert restored is False, "it genuinely could not be replayed" + text = agent.read_text() + assert "<<<<<<<" not in text and ">>>>>>>" not in text, text + assert text == "# Scout v2\nUPSTREAM rule.\n", "the tree is the update's own" + + unmerged = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=U"], + cwd=repo, + capture_output=True, + text=True, + env=ENV, + ).stdout + assert unmerged.strip() == "", f"index left unmerged: {unmerged}" + + # The work survives, and the message points at where. + assert await updater.has_update_stash(str(repo)) is True + assert "stash@{0}" in message and "nothing is half-merged" in message + + # And the abort did not reach past the stash's own paths. + assert bystander.read_text() == "do not lose me\n" + + +@pytest.mark.asyncio +async def test_a_stash_that_replays_cleanly_is_dropped(stashed, tmp_path): + """The common case still completes — and does not leave the entry behind.""" + repo, agent, _ = stashed + # Drop the edit that collides, so the fast-forward is unobstructed, and + # park something the update does not touch instead. + _git("checkout", "--", "agents/scout/AGENT.md", cwd=repo) + other = repo / "agents" / "scout" / "notes.md" + other.write_text("my notes\n") + + ok, _ = await updater.stash_paths(str(repo), ["agents/scout/notes.md"]) + assert ok + moved, output = await updater.fast_forward(str(repo)) + assert moved, output + + restored, message = await updater.stash_pop(str(repo)) + + assert restored is True, message + assert other.read_text() == "my notes\n" + assert await updater.has_update_stash(str(repo)) is False, "the entry was kept" + + +@pytest.mark.asyncio +async def test_nothing_of_ours_stashed_is_not_a_failure(repo): + assert await updater.stash_pop(str(repo)) == ( + True, + "Nothing of ours was stashed.", + ) + + +@pytest.mark.asyncio +async def test_work_left_in_a_stash_is_reported_by_the_next_preflight( + stashed, monkeypatch +): + """The stash outlived the run that made it and nothing mentioned it again. + + `git status` does not show stashes, the finished run scrolls away, and the + next preflight only looks at the working tree — so parked work could sit + there indefinitely with every surface reporting a healthy install. + """ + repo, _, _ = stashed + await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + + monkeypatch.setattr( + components, + "_table", + lambda: { + components.HUMMINGBOT_API: components.Component( + components.HUMMINGBOT_API, + "Hummingbot API", + str(repo / "no-such-api"), + service="hummingbot-api", + ), + components.CONDOR: components.Component( + components.CONDOR, "Condor", str(repo) + ), + }, + ) + preflight = await components.preflight([components.CONDOR]) + + codes = [w.code for w in preflight.warnings] + assert "stashed-work" in codes, codes + message = next(w.message for w in preflight.warnings if w.code == "stashed-work") + assert "condor /update" in message + + +@pytest.mark.asyncio +async def test_a_clean_checkout_gets_no_stash_warning(repo, monkeypatch): + monkeypatch.setattr( + components, + "_table", + lambda: { + components.HUMMINGBOT_API: components.Component( + components.HUMMINGBOT_API, + "Hummingbot API", + str(repo / "no-such-api"), + service="hummingbot-api", + ), + components.CONDOR: components.Component( + components.CONDOR, "Condor", str(repo) + ), + }, + ) + preflight = await components.preflight([components.CONDOR]) + assert "stashed-work" not in [w.code for w in preflight.warnings] + + +# ── Review findings on the resolutions themselves ── + + +@pytest.mark.asyncio +async def test_our_stash_is_found_by_name_not_by_position(stashed): + """`stash apply`/`drop` default to `stash@{0}`, and the stack reorders. + + With an operator stash pushed after ours, "a condor stash exists + somewhere" plus "act on the top one" applied *and dropped* theirs, + restored unrelated work into the tree nobody asked for, left ours parked, + and reported success. + """ + repo, agent, bystander = stashed + # Park something the update does not touch, so the replay applies cleanly + # and the drop is reached — that is where the wrong entry was destroyed. + _git("checkout", "--", "agents/scout/AGENT.md", cwd=repo) + (repo / "agents" / "scout" / "notes.md").write_text("my notes\n") + await updater.stash_paths(str(repo), ["agents/scout/notes.md"]) + + bystander.write_text("operator's own edit\n") + _git("stash", "push", "-u", "-m", "my wip", "--", bystander.name, cwd=repo) + assert ( + await updater.update_stash_ref(str(repo)) == "stash@{1}" + ), "ours is not on top" + + moved, output = await updater.fast_forward(str(repo)) + assert moved, output + restored, message = await updater.stash_pop(str(repo)) + assert restored, message + + listing = subprocess.run( + ["git", "stash", "list"], cwd=repo, capture_output=True, text=True, env=ENV + ).stdout + assert "my wip" in listing, "the operator's stash was consumed" + assert "condor /update" not in listing, "ours should have been dropped" + assert (repo / "agents" / "scout" / "notes.md").read_text() == "my notes\n" + # Their work stayed parked rather than being dumped into the tree. + assert not bystander.exists() or bystander.read_text() != "operator's own edit\n" + + +@pytest.mark.asyncio +async def test_keep_mine_refuses_to_overwrite_an_existing_local_copy(repo, tmp_path): + """Edited through the product *and* in the checkout: two customizations. + + `shutil.move` replaced the first with the second and reported that the + version was kept — one of them destroyed, silently. + """ + from condor.paths import local_agents_root + + local = local_agents_root() / "scout" + local.mkdir(parents=True, exist_ok=True) + (local / "AGENT.md").write_text("MADE THROUGH THE PRODUCT\n") + (repo / "agents" / "scout" / "AGENT.md").write_text("# Scout v1\nEditor tweak.\n") + + ok, message = await updater.move_to_local_root(str(repo), ["agents/scout/AGENT.md"]) + + assert ok is False + assert (local / "AGENT.md").read_text() == "MADE THROUGH THE PRODUCT\n" + assert "already have your own" in message + # And the checkout copy is still there to compare against. + assert (repo / "agents" / "scout" / "AGENT.md").exists() + + +@pytest.mark.asyncio +async def test_keep_mine_on_an_untracked_file_reports_success(repo): + """`keep-mine` is offered for an `untracked-conflict` too. + + `checkout HEAD -- ` fails on a path HEAD never had, so the whole + resolution reported failure *after* moving the file. Nothing needs + resetting for an untracked path — moving it was the resolution. + """ + (repo / "agents" / "scout" / "invented.md").write_text("mine alone\n") + + ok, message = await updater.move_to_local_root( + str(repo), ["agents/scout/invented.md"] + ) + + assert ok is True, message + assert not (repo / "agents" / "scout" / "invented.md").exists() + + +# ── Second review pass: defects in the fixes above ── + + +@pytest.mark.asyncio +async def test_a_batch_move_checks_every_destination_before_moving_any(repo): + """A clash found half way through left the earlier files already gone. + + The refusal said "nothing was moved" while the checkout showed a deletion + nobody made, and the update stayed blocked. + """ + from condor.paths import local_agents_root + + (repo / "agents" / "scout" / "second.md").write_text("shipped\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "two files", cwd=repo) + + clash = local_agents_root() / "scout" / "second.md" + clash.parent.mkdir(parents=True, exist_ok=True) + clash.write_text("MADE THROUGH THE PRODUCT\n") + for name in ("AGENT.md", "second.md"): + (repo / "agents" / "scout" / name).write_text("shipped\nmy edit\n") + + ok, message = await updater.move_to_local_root( + str(repo), ["agents/scout/AGENT.md", "agents/scout/second.md"] + ) + + assert ok is False + assert (repo / "agents" / "scout" / "AGENT.md").exists(), "moved despite refusing" + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo, + capture_output=True, + text=True, + env=ENV, + ).stdout + assert " D " not in status and not status.startswith("D "), status + assert "second.md" in message and "AGENT.md" not in message + + +@pytest.mark.asyncio +async def test_a_staged_addition_has_its_index_entry_cleared(repo): + """`checkout HEAD --` cannot touch a path HEAD never had. + + The staged entry survived the move, so the incoming commit that adds the + same path still had something to collide with — reported as a success. + """ + (repo / "agents" / "scout" / "added.md").write_text("I staged this\n") + _git("add", "agents/scout/added.md", cwd=repo) + + ok, message = await updater.move_to_local_root(str(repo), ["agents/scout/added.md"]) + + assert ok is True, message + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo, + capture_output=True, + text=True, + env=ENV, + ).stdout.strip() + assert status == "", f"index entry survived: {status!r}" + + +@pytest.mark.asyncio +async def test_the_abort_advice_names_our_stash_not_the_top_one(stashed): + """Ours is not necessarily `stash@{0}` — anything pushed later sits above. + + The recovery line hardcoded `stash@{0}` and bare `git stash pop`, so + following it restored somebody else's work: the same mistake the lookup + had just been fixed to stop making. + """ + repo, agent, bystander = stashed + await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + bystander.write_text("operator's own edit\n") + _git("stash", "push", "-u", "-m", "someone else", "--", bystander.name, cwd=repo) + + moved, output = await updater.fast_forward(str(repo)) + assert moved, output + restored, message = await updater.stash_pop(str(repo)) + + assert restored is False, "this replay is expected to conflict" + assert "git stash pop stash@{1}" in message, message + assert "stash@{0}" not in message diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d3b8ae378..59e2cd824 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -479,3 +479,137 @@ def test_hummingbot_api_check_fails_when_env_exists_but_config_does_not( checks = doctor.check_hummingbot_api() assert checks[0].state == doctor.FAIL + + +# ── The dashboard-port check reads the bind it was given (macOS lsof) ── + + +def test_a_loopback_bind_is_not_reported_as_public(): + """``lsof -sTCP:LISTEN`` prints the state as its own trailing token. + + NAME is ``TCP 127.0.0.1:8088 (LISTEN)``, so the last column is + ``(LISTEN)``. Taking it and splitting on "(" produced the empty string, + which counted as a wildcard — so on any host without ``ss`` (macOS, and + WSL without iproute2) a loopback-only dashboard was reported as reachable + on all interfaces, and in local mode that is a FAIL whose remedy is to + unset a variable nobody set. + """ + import socket + + from condor.doctor import _is_public_bind, _listening_binds + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + try: + binds = _listening_binds(sock.getsockname()[1]) + assert binds, "nothing parsed out of the listener listing" + assert all(not _is_public_bind(b) for b in binds), binds + assert all(b.startswith("127.0.0.1:") for b in binds), binds + finally: + sock.close() + + +def test_a_wildcard_bind_is_still_reported_as_public(): + """The check has to keep catching the thing it exists for.""" + import socket + + from condor.doctor import _is_public_bind, _listening_binds + + sock = socket.socket() + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", 0)) + sock.listen(1) + try: + binds = _listening_binds(sock.getsockname()[1]) + assert binds and any(_is_public_bind(b) for b in binds), binds + finally: + sock.close() + + +def test_an_unparseable_bind_is_unknown_rather_than_public(): + """A bind this code could not read is not evidence of exposure.""" + from condor.doctor import _is_public_bind + + assert _is_public_bind("") is False + assert _is_public_bind("0.0.0.0:8088") is True + assert _is_public_bind("[::]:8088") is True + + +# ── The id the dashboard signs in as must be able to administer (V10) ── + + +def _admin_checks(tmp_path, monkeypatch, *, admin_id, users, env_lines=None): + import condor.doctor as doctor_module + + env = tmp_path / ".env" + env.write_text("\n".join(env_lines or [f"ADMIN_USER_ID={admin_id}"]) + "\n") + monkeypatch.setattr(doctor_module, "ENV_PATH", env) + monkeypatch.setattr("utils.config.ADMIN_USER_ID", admin_id) + monkeypatch.setattr("utils.config.LOCAL_MODE", True) + return doctor_module._check_admin_can_administer({"users": users}) + + +def test_a_matching_admin_passes_quietly(tmp_path, monkeypatch): + assert ( + _admin_checks(tmp_path, monkeypatch, admin_id=1, users={1: {"role": "admin"}}) + == [] + ) + + +def test_an_id_config_yml_calls_a_user_is_reported(tmp_path, monkeypatch): + """Two files have to agree about one number, and nothing checked that. + + `.env` decides who the dashboard *is* — local mode signs in as + `ADMIN_USER_ID` with no password — and `config.yml` decides what that id + may *do*. Disagree and every admin route answers 403: no Updates tab, and + the "Updates available" notification links to a tab that is not rendered. + The old check asked only whether the variable was set, so an install in + exactly this state reported `ADMIN_USER_ID` green. + """ + checks = _admin_checks( + tmp_path, + monkeypatch, + admin_id=777, + users={1: {"role": "admin"}, 777: {"role": "user"}}, + ) + assert [c.name for c in checks] == ["Admin access"] + detail = checks[0].detail + assert "777" in detail and "'user'" in detail + assert "role: admin" in detail, "the remedy has to name the knob" + + +def test_an_id_config_yml_has_never_heard_of_is_reported(tmp_path, monkeypatch): + checks = _admin_checks( + tmp_path, monkeypatch, admin_id=999, users={1: {"role": "admin"}} + ) + assert [c.name for c in checks] == ["Admin access"] + assert "no user 999" in checks[0].detail + + +def test_a_duplicated_assignment_is_named_on_its_own(tmp_path, monkeypatch): + """`.env` is read by three parsers and the last assignment wins in all of + them, so a second line is silently authoritative and the first is the one + people read.""" + checks = _admin_checks( + tmp_path, + monkeypatch, + admin_id=777, + users={777: {"role": "admin"}}, + env_lines=["ADMIN_USER_ID=1", "OTHER=x", "ADMIN_USER_ID=777"], + ) + assert [c.name for c in checks] == ["ADMIN_USER_ID (duplicate)"] + assert "the last one wins (777)" in checks[0].detail + + +def test_one_assignment_and_a_good_role_says_nothing(tmp_path, monkeypatch): + assert ( + _admin_checks( + tmp_path, + monkeypatch, + admin_id=777, + users={777: {"role": "admin"}}, + env_lines=["ADMIN_USER_ID=777", "OTHER=x"], + ) + == [] + ) diff --git a/tests/test_image_tracks_checkout.py b/tests/test_image_tracks_checkout.py new file mode 100644 index 000000000..2d50eaa9c --- /dev/null +++ b/tests/test_image_tracks_checkout.py @@ -0,0 +1,176 @@ +"""The container does not run the checkout, so only offer what can match (H1). + +hummingbot-api's CI publishes on merges to the default branch and nothing else, +tagging only ``latest`` and the version. There is no per-branch tag, so on any +other checkout the published image has nothing to do with the code on disk and +"pull the newer image" means "silently run the default branch's code". + +The rule is therefore two-way: either this checkout is the one the tag is built +from, or the operator owns the image. These tests pin the four conditions that +make up "is", because the branch *name* alone gets each of them wrong. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor.updates import components +from utils import updater + +CANONICAL = "https://github.com/hummingbot/hummingbot-api.git" + + +def _git(*args, cwd): + subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + env={ + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", + }, + ) + + +@pytest.fixture +def checkout(tmp_path): + """A clone of a local "canonical" origin, on its default branch, clean.""" + origin = tmp_path / "origin" + origin.mkdir() + _git("init", "-q", "-b", "main", cwd=origin) + for i in range(2): + (origin / "f.txt").write_text(f"{i}\n") + _git("add", "-A", cwd=origin) + _git("commit", "-qm", f"c{i}", cwd=origin) + + clone = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(origin), str(clone)], check=True, capture_output=True + ) + # Present the clone as the canonical repository; origin/HEAD already points + # at main from the clone. + _git("remote", "set-url", "origin", CANONICAL, cwd=clone) + return clone + + +# ── the canonical-remote condition ── + + +def test_a_fork_is_not_treated_as_the_canonical_repository(): + assert components._is_canonical_remote(CANONICAL) is True + assert ( + components._is_canonical_remote("git@github.com:hummingbot/hummingbot-api.git") + is True + ) + assert ( + components._is_canonical_remote("https://github.com/someone/hummingbot-api") + is False + ) + assert components._is_canonical_remote("https://example.com/mirror") is False + + +@pytest.mark.asyncio +async def test_the_default_branch_of_the_canonical_repo_tracks_the_image(checkout): + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is True, why + + +@pytest.mark.asyncio +async def test_a_forks_main_does_not_track_the_image(checkout): + _git( + "remote", + "set-url", + "origin", + "https://github.com/someone/hummingbot-api", + cwd=checkout, + ) + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "not the repository the image is built from" in why + + +@pytest.mark.asyncio +async def test_a_feature_branch_does_not_track_the_image(checkout): + _git("checkout", "-qb", "feat/add-https", cwd=checkout) + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "feat/add-https" in why and "main" in why + + +@pytest.mark.asyncio +async def test_a_detached_head_does_not_track_the_image(checkout): + _git("checkout", "-q", "--detach", "HEAD~1", cwd=checkout) + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "detached" in why + + +@pytest.mark.asyncio +async def test_unpushed_commits_mean_the_code_is_not_the_default_branch(checkout): + """The branch is `main`; the code is not. The name alone cannot see this.""" + (checkout / "mine.txt").write_text("local work\n") + _git("add", "-A", cwd=checkout) + _git("commit", "-qm", "my own commit", cwd=checkout) + + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "not on `main`" in why + + +# ── what the operator is shown ── + + +@pytest.mark.asyncio +async def test_an_untracked_checkout_is_not_offered_an_image_pull( + monkeypatch, checkout +): + """The offer disappears and the reason is stated -- not silently skipped.""" + _git("checkout", "-qb", "feat/mine", cwd=checkout) + + async def _service(*a, **k): + return {"image": "hummingbot/hummingbot-api:latest"} + + monkeypatch.setattr(updater, "compose_service", _service) + monkeypatch.setattr( + updater, + "local_image_identity", + lambda *a, **k: _val(("sha256:id", "sha256:aaa")), + ) + monkeypatch.setattr( + updater, "registry_image_digest", lambda *a, **k: _val("sha256:bbb") + ) + monkeypatch.setattr( + components, + "_table", + lambda: { + components.HUMMINGBOT_API: components.Component( + key=components.HUMMINGBOT_API, + name="Hummingbot API", + repo_dir=str(checkout), + service="hummingbot-api", + ) + }, + ) + + status = await components.status(components.HUMMINGBOT_API) + image = status.facets["image"] + assert image.behind == 0 + assert image.up_to_date is True + assert image.available is None + detail = " ".join(image.detail) + assert "you own this image" in detail.lower() + assert "feat/mine" in detail + + steps = components._steps_for(components.HUMMINGBOT_API, status) + assert not any("Pull the published" in s for s in steps) + assert not any("Recreate the containers" in s for s in steps) + + +async def _val(v): + return v diff --git a/tests/test_repo_portability.py b/tests/test_repo_portability.py new file mode 100644 index 000000000..104d520d3 --- /dev/null +++ b/tests/test_repo_portability.py @@ -0,0 +1,85 @@ +"""The checkout has to work on macOS and Windows too (P5, P2). + +Condor is developed on Linux, where the filesystem is case-sensitive and line +endings are LF. Both of the other supported hosts differ, and both differences +are silent: a case-collision makes a clean clone impossible on APFS or NTFS, and +an unmanaged line ending changes the bytes `content_digest` hashes. + +These are cheap assertions against the tree itself, so a commit that breaks +either one fails here rather than on someone's laptop. +""" + +from __future__ import annotations + +import subprocess +from collections import defaultdict +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _tracked_files() -> list[str]: + result = subprocess.run( + ["git", "ls-files"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def test_no_two_tracked_paths_differ_only_in_case(): + """macOS (APFS) and Windows (NTFS) are case-insensitive by default. + + Two paths differing only in case are one file there, so a clone silently + loses one of them — and the fork layering, which resolves local-before-stock + per item, then resolves unpredictably. + """ + by_lower: dict[str, list[str]] = defaultdict(list) + for path in _tracked_files(): + by_lower[path.lower()].append(path) + + collisions = {k: v for k, v in by_lower.items() if len(v) > 1} + assert not collisions, f"paths differing only in case: {collisions}" + + +def test_agent_content_is_pinned_to_lf(): + """`content_digest` hashes these files, so their bytes must not vary by host.""" + attributes = (_REPO_ROOT / ".gitattributes").read_text() + assert "* text=auto" in attributes + for pattern in ("*.md text eol=lf", "*.sh text eol=lf"): + assert pattern in attributes, f"{pattern} is not pinned" + + +def test_no_tracked_file_has_crlf_endings(): + """`* text=auto` normalizes on commit; this catches one that slipped in. + + Asked of the *index*, not the working tree. `text=auto` guarantees LF in + what git stores, and says nothing about what it materializes: a checkout + with `core.autocrlf=true` — the Windows default, and inherited by a WSL2 + clone under /mnt/c — writes CRLF into the working tree for exactly the + files this is meant to pass. Reading bytes off disk there fails every + ordinary `.py` and `.tsx` and reports it as a portability defect, which is + the opposite of what this test is for. + + `git ls-files --eol` reports both, as `i/ w/`. `i/crlf` is a + file committed with CRLF, which is the thing worth catching. + """ + proc = subprocess.run( + ["git", "ls-files", "--eol", "-z"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + offenders = [] + for entry in proc.stdout.split("\0"): + if not entry.strip(): + continue + # "i/crlf w/crlf attr/ \tpath/to/file" + fields, _, rel = entry.partition("\t") + index_eol = fields.split()[0] if fields.split() else "" + if index_eol == "i/crlf": + offenders.append(rel) + assert not offenders, f"CRLF committed in: {offenders}" diff --git a/tests/test_setup_chrome.py b/tests/test_setup_chrome.py new file mode 100644 index 000000000..b7d65ae03 --- /dev/null +++ b/tests/test_setup_chrome.py @@ -0,0 +1,158 @@ +"""`make install` must not be able to hang on an optional renderer (V11). + +The bare `python -c "import kaleido; kaleido.get_chrome_sync()"` this replaces +had no timeout and sent stderr to /dev/null. Measured when it stalled: 256 KB +of a ~150 MB archive after thirty minutes, connection open and idle, the only +line on screen "Setting up Chrome for chart rendering...". The install could +not finish and nothing said why. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from condor import setup_chrome + + +def test_an_already_installed_chrome_costs_no_download(tmp_path, monkeypatch): + """Re-running `make install` must not re-fetch 150 MB every time.""" + exe = tmp_path / "chrome" + exe.write_text("x") + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: exe) + + def no_popen(*a, **kw): # pragma: no cover - reached only on regression + raise AssertionError("an installed browser must not be downloaded again") + + monkeypatch.setattr(setup_chrome.subprocess, "Popen", no_popen) + + ok, message = setup_chrome.fetch() + assert ok is True + assert "Already installed" in message + + +def test_an_unsupported_platform_is_skipped_not_attempted(monkeypatch): + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: None) + ok, message = setup_chrome.fetch() + assert ok is False + assert "no Chrome-for-Testing build" in message + + +class _FakeProc: + """A download that never finishes and never writes another byte.""" + + returncode = None + stdout = None + + def __init__(self): + self.killed = False + + def poll(self): + return None if not self.killed else -9 + + def kill(self): + self.killed = True + self.returncode = -9 + + def wait(self): + return self.returncode + + +def _run_with(monkeypatch, tmp_path, proc, sizes): + """Drive `fetch` over a scripted size sequence with time under our control.""" + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: tmp_path / "absent") + monkeypatch.setattr(setup_chrome, "_download_root", lambda: tmp_path) + monkeypatch.setattr(setup_chrome.subprocess, "Popen", lambda *a, **kw: proc) + monkeypatch.setattr(setup_chrome.time, "sleep", lambda _s: None) + + clock = {"t": 0.0} + seq = list(sizes) + + def fake_monotonic(): + return clock["t"] + + def fake_bytes(_root): + clock["t"] += setup_chrome._POLL + return seq.pop(0) if seq else (seq_last[0] if seq_last else 0) + + seq_last = [sizes[-1]] if sizes else [0] + monkeypatch.setattr(setup_chrome.time, "monotonic", fake_monotonic) + monkeypatch.setattr(setup_chrome, "downloaded_bytes", fake_bytes) + return setup_chrome.fetch(timeout=10_000, stall=30) + + +def test_a_download_that_stops_moving_is_given_up_on(tmp_path, monkeypatch, capsys): + """The exact failure seen: bytes arrive, then stop, and nothing ever ends.""" + proc = _FakeProc() + ok, message = _run_with(monkeypatch, tmp_path, proc, [0, 262144] + [262144] * 60) + + assert ok is False + assert proc.killed is True, "the child was left running" + assert "Stalled" in message and "0.3 MB" in message + assert "make setup-chrome" in message, "the remedy has to be nameable" + + +def test_a_slow_but_moving_download_is_not_called_stalled(tmp_path, monkeypatch): + """A cold fetch over a slow link must not be mistaken for a wedged one.""" + proc = _FakeProc() + growing = [i * 500_000 for i in range(200)] + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: tmp_path / "absent") + monkeypatch.setattr(setup_chrome, "_download_root", lambda: tmp_path) + monkeypatch.setattr(setup_chrome.subprocess, "Popen", lambda *a, **kw: proc) + monkeypatch.setattr(setup_chrome.time, "sleep", lambda _s: None) + clock = {"t": 0.0} + seq = list(growing) + monkeypatch.setattr(setup_chrome.time, "monotonic", lambda: clock["t"]) + + def sizes(_root): + clock["t"] += setup_chrome._POLL + return seq.pop(0) if seq else growing[-1] + + monkeypatch.setattr(setup_chrome, "downloaded_bytes", sizes) + + ok, message = setup_chrome.fetch(timeout=120, stall=30) + assert "Stalled" not in message, message + assert ok is False and "Gave up after 120s" in message + assert "CONDOR_CHROME_TIMEOUT" in message, "say which knob to turn" + + +def test_main_never_fails_the_install(monkeypatch, capsys): + """Charts are optional; `make install` must survive every outcome.""" + monkeypatch.setattr( + setup_chrome, "fetch", lambda *a, **kw: (False, "Stalled: nothing arrived.") + ) + assert setup_chrome.main() == 0 + assert "Stalled" in capsys.readouterr().out + + +def test_downloaded_bytes_tolerates_a_missing_directory(tmp_path): + assert setup_chrome.downloaded_bytes(tmp_path / "nope") == 0 + (tmp_path / "a").write_bytes(b"x" * 10) + assert setup_chrome.downloaded_bytes(tmp_path) == 10 + + +def test_no_size_signal_means_no_stall_verdict(tmp_path, monkeypatch): + """Without a download root the counter is pinned at 0 forever. + + `last_change` then never moves and the stall branch fires on a download + that is progressing fine — killing it and blaming a proxy. + """ + proc = _FakeProc() + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: tmp_path / "absent") + monkeypatch.setattr(setup_chrome, "_download_root", lambda: None) + monkeypatch.setattr(setup_chrome.subprocess, "Popen", lambda *a, **kw: proc) + monkeypatch.setattr(setup_chrome.time, "sleep", lambda _s: None) + clock = {"t": 0.0} + + def monotonic(): + clock["t"] += setup_chrome._POLL + return clock["t"] + + monkeypatch.setattr(setup_chrome.time, "monotonic", monotonic) + + ok, message = setup_chrome.fetch(timeout=60, stall=10) + + assert ok is False + assert "Stalled" not in message, message + assert "Gave up after 60s" in message, "the ceiling should be what stops it" diff --git a/tests/test_shared_routines.py b/tests/test_shared_routines.py index e0af1378f..0c3195c48 100644 --- a/tests/test_shared_routines.py +++ b/tests/test_shared_routines.py @@ -298,7 +298,13 @@ def test_chat_create_without_shared_stays_local(self, isolated, monkeypatch): assert result["created"] is True assert "shared" not in result - assert (tmp_path / "routines" / "local.py").exists() + # The chat's own writable library, which is now the gitignored local + # root rather than the tracked repo-root one: what the chat agent + # authors must not land in a directory upstream also maintains. + assert ( + paths_mod.local_agents_root() / CHAT_SLUG / "routines" / "local.py" + ).exists() + assert not (tmp_path / "routines" / "local.py").exists() assert not (paths_mod.shared_routines_root() / "local.py").exists() def test_agent_edit_and_delete_cannot_reach_the_shared_root( diff --git a/tests/test_stale_forks.py b/tests/test_stale_forks.py new file mode 100644 index 000000000..3ee5c91dc --- /dev/null +++ b/tests/test_stale_forks.py @@ -0,0 +1,294 @@ +"""A fork stops receiving upstream changes — and now says so (C11). + +FEAT-115 solved the loud failure: a customized playbook no longer blocks an +update. It introduced the quiet one. ``fork_path`` stamps the copy with +``forked_from`` and nothing ever read the stamp back, so the update succeeded, +upstream's rewrite landed in the shipped tree, and the agent went on reading the +local copy — for ever, with no notice, on files that carry trading rules. + +These tests pin the comparison that was missing. They do not pin a merge: local +still wins. What is asserted is that the operator is *told*. +""" + +from __future__ import annotations + +import pytest + +from condor.layering import ( + FORKED_FROM_KEY, + content_digest, + fork_if_stock, + stale_forks, + write_preserving_stamp, +) +from condor.memory.paths import agent_home_layers, stock_agent_home + +AGENT_MD = "---\nname: {name}\n---\n\n{body}\n" + + +def _write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, "utf-8") + return path + + +@pytest.fixture +def shipped(): + """One shipped playbook carrying a rule upstream will later tighten.""" + return _write( + stock_agent_home("scout") / "AGENT.md", + AGENT_MD.format(name="Scout", body="Never trade on Sundays."), + ) + + +def test_an_untouched_install_has_no_stale_forks(shipped): + assert stale_forks("scout") == [] + + +def test_a_fork_whose_stock_file_never_moved_is_not_stale(shipped): + fork_if_stock("scout", "AGENT.md") + assert stale_forks("scout") == [] + + +def test_a_fork_is_reported_once_upstream_rewrites_the_stock_file(shipped): + """The whole scenario, end to end: fork, then an update rewrites stock.""" + target = fork_if_stock("scout", "AGENT.md") + write_preserving_stamp( + target, AGENT_MD.format(name="Scout", body="MY EDIT: also never Mondays.") + ) + assert stale_forks("scout") == [], "nothing has changed upstream yet" + + # What an update does to the shipped tree. + shipped.write_text( + AGENT_MD.format( + name="Scout", body="UPSTREAM: never trade on Sundays OR holidays." + ), + "utf-8", + ) + + stale = stale_forks("scout") + assert len(stale) == 1 + (fork,) = stale + assert fork.rel == "AGENT.md" + assert fork.retired is False + assert fork.stock_digest == content_digest(shipped) + assert fork.forked_from != fork.stock_digest + + # And the agent is still reading the local copy — the report is the fix, + # not a silent takeover. + assert "MY EDIT" in target.read_text("utf-8") + + +def test_a_retired_agent_is_reported_as_retired(shipped): + """Upstream withdrawing a playbook does not propagate, so it must be said.""" + fork_if_stock("scout", "AGENT.md") + shipped.unlink() + + stale = stale_forks("scout") + assert len(stale) == 1 + assert stale[0].retired is True + assert stale[0].stock_digest is None + + +def test_a_file_the_operator_authored_is_never_stale(shipped): + """No stamp means it was never a fork, so there is nothing to diverge from.""" + local, _ = agent_home_layers("scout") + _write(local / "MINE.md", AGENT_MD.format(name="mine", body="All my own work.")) + assert stale_forks("scout") == [] + + +def test_a_legacy_crlf_stamp_is_not_reported_stale(shipped): + """Stamps written before newline normalization must not all fire at once. + + A stamp taken from a CRLF working tree holds the raw-bytes hash. After + normalization that compares as changed, which would report every such fork + stale for a reason that has nothing to do with upstream. + """ + import hashlib + + target = fork_if_stock("scout", "AGENT.md") + shipped.write_bytes(shipped.read_bytes().replace(b"\n", b"\r\n")) + legacy = f"sha256:{hashlib.sha256(shipped.read_bytes()).hexdigest()[:12]}" + + text = target.read_text("utf-8") + target.write_text( + text.replace( + [ln for ln in text.splitlines() if ln.startswith(FORKED_FROM_KEY)][0], + f"{FORKED_FROM_KEY}: {legacy}", + ), + "utf-8", + ) + assert stale_forks("scout") == [] + + +# ── Files that cannot carry a stamp, and files that never had one ── + + +def test_a_shipped_routine_edited_locally_is_reported(shipped): + """A routine's ``.py`` has no frontmatter, so it can never hold a stamp. + + The library ships nineteen of them, and ``fork_path`` copies one down + unstamped by design — inventing frontmatter would change what the file is + and break the machinery that imports it. So the stamp cannot be the only + way divergence is noticed: the local copy shadows the shipped one for ever + and nothing in the stamp-based check would ever see it. + """ + from condor.layering import fork_if_stock + + stock_routine = shipped.parent / "routines" / "scanner.py" + stock_routine.parent.mkdir(parents=True, exist_ok=True) + stock_routine.write_text("# shipped scanner\n") + + local_routine = fork_if_stock("scout", "routines", "scanner.py") + local_routine.write_text("# my own scanner\n") + + stale = stale_forks("scout") + assert [f.rel for f in stale] == ["routines/scanner.py"] + assert stale[0].unprovenanced is True + assert stale[0].retired is False + + +def test_a_forked_copy_that_never_diverged_is_not_reported(shipped): + """Copying is not diverging — an identical unstamped copy has nothing to say.""" + from condor.layering import fork_if_stock + + stock_routine = shipped.parent / "routines" / "scanner.py" + stock_routine.parent.mkdir(parents=True, exist_ok=True) + stock_routine.write_text("# shipped scanner\n") + + fork_if_stock("scout", "routines", "scanner.py") # copied, not edited + + assert stale_forks("scout") == [] + + +def test_an_agent_authored_file_upstream_later_ships_is_reported(shipped): + """A name collision shadows exactly like a fork, with no stamp to show it.""" + from condor.memory.paths import agent_home_layers + + local, stock = agent_home_layers("scout") + (local / "skills" / "alpha").mkdir(parents=True) + (local / "skills" / "alpha" / "SKILL.md").write_text("# mine\n") + assert stale_forks("scout") == [], "nothing upstream ships this name yet" + + (stock / "skills" / "alpha").mkdir(parents=True) + (stock / "skills" / "alpha" / "SKILL.md").write_text("# upstream ships this now\n") + + stale = stale_forks("scout") + assert [f.rel for f in stale] == ["skills/alpha/SKILL.md"] + assert stale[0].unprovenanced is True + + +def test_a_brand_new_agent_file_is_never_reported(shipped): + """Authored, with no shipped counterpart: there is nothing to diverge from.""" + from condor.memory.paths import agent_home_layers + + local, _ = agent_home_layers("scout") + (local / "routines").mkdir(parents=True) + (local / "routines" / "my_own.py").write_text("# all my own work\n") + + assert stale_forks("scout") == [] + + +# ── The predictive half: what *this* update would change ── + + +def test_the_incoming_set_is_checked_against_local_overrides(shipped): + """stale_forks() is retrospective; this answers before the update lands.""" + from condor.layering import fork_if_stock, locally_overridden + + fork_if_stock("scout", "AGENT.md") + + overridden = locally_overridden( + [ + "agents/scout/AGENT.md", + "agents/keeper/AGENT.md", + "main.py", + "frontend/src/App.tsx", + ] + ) + # Only the one this install actually has its own copy of. + assert overridden == ["agents/scout/AGENT.md"] + + +def test_nothing_is_overridden_on_an_untouched_install(shipped): + from condor.layering import locally_overridden + + assert locally_overridden(["agents/scout/AGENT.md", "main.py"]) == [] + + +# ── The report names which library each item came from ── + + +def test_two_agents_with_the_same_filename_are_told_apart(shipped): + """``rel`` alone renders every forked playbook as a bare ``AGENT.md``. + + Nine agents ship, so an install that has customized more than one is the + ordinary case — and "(AGENT.md, AGENT.md, AGENT.md)" names none of them. + """ + from condor.layering import all_stale_forks + + other = _write( + stock_agent_home("brigado") / "AGENT.md", + AGENT_MD.format(name="Brigado", body="Hold overnight."), + ) + for slug in ("scout", "brigado"): + fork_if_stock(slug, "AGENT.md") + # Upstream rewrites both. + for path, body in ((shipped, "UPSTREAM scout."), (other, "UPSTREAM brigado.")): + path.write_text(AGENT_MD.format(name="x", body=body), "utf-8") + + labels = sorted(f.label for f in all_stale_forks()) + assert labels == ["brigado/AGENT.md", "scout/AGENT.md"] + # ``rel`` keeps its meaning — relative to the library it belongs to. + assert {f.rel for f in all_stale_forks()} == {"AGENT.md"} + + +# ── The shared library is scanned too ── + + +@pytest.mark.parametrize( + ("rel", "body"), + [ + # A skill: markdown, so it could carry a stamp — this one does not, + # because a hand copy is how these actually get customized. + (("skills", "charting", "SKILL.md"), "---\nname: Charting\n---\n\n{tag}\n"), + # A routine: .py cannot hold frontmatter, so it never has a stamp. + (("routines", "backtest_chart.py"), "# {tag}\n"), + # A controller: the shape FEAT-126 added, same layering rule. + (("controllers", "pmm_v1", "pmm_v1.py"), "# {tag}\n"), + ], +) +def test_a_customized_shared_item_is_reported(rel, body): + """``_shared`` is read by every agent but is not an agent. + + ``iter_agent_slugs`` is built on ``_is_agent_dir``, which rejects a leading + underscore — so the shared skills, routines and controllers were the one + tree that no scan ever reached, and a customized one diverged in silence. + """ + from condor.layering import all_stale_forks + from condor.memory.paths import local_agents_root, stock_agents_root + + shipped = _write( + stock_agents_root().joinpath("_shared", *rel), body.format(tag="shipped") + ) + _write(local_agents_root().joinpath("_shared", *rel), body.format(tag="mine")) + + # Identical-but-for-my-edit already differs, so it reports immediately; + # what matters is that an upstream rewrite keeps reporting it. + shipped.write_text(body.format(tag="UPSTREAM rewrote this"), "utf-8") + + stale = [f for f in all_stale_forks() if f.owner == "_shared"] + assert [f.label for f in stale] == ["_shared/" + "/".join(rel)] + assert stale[0].unprovenanced is True + + +def test_an_identical_shared_copy_is_not_reported(): + """Copying is not diverging — the same rule the per-agent scan applies.""" + from condor.layering import all_stale_forks + from condor.memory.paths import local_agents_root, stock_agents_root + + text = "---\nname: Charting\n---\n\nSame.\n" + _write(stock_agents_root() / "_shared" / "skills" / "charting" / "SKILL.md", text) + _write(local_agents_root() / "_shared" / "skills" / "charting" / "SKILL.md", text) + + assert [f for f in all_stale_forks() if f.owner == "_shared"] == [] diff --git a/tests/test_update_frontend_build.py b/tests/test_update_frontend_build.py new file mode 100644 index 000000000..a26186a7c --- /dev/null +++ b/tests/test_update_frontend_build.py @@ -0,0 +1,216 @@ +"""The dashboard bundle is not destroyed to build its replacement (C1, C13). + +vite defaults to ``outDir: "dist"`` with ``emptyOutDir: true``, and +``vite.config.ts`` overrides neither — so the build emptied the directory +uvicorn was serving out of, for the whole length of the build. A reload in that +window returned 500 (a missing ``index.html`` raises inside ``FileResponse``, +which nothing upstream can tell from a crash), and a build that *failed* left +the install with no dashboard at all while the run said the previous bundle +would come back. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +import condor.web.app as web_app +from condor.web.app import _adopt_orphaned_bundle, create_app +from utils import updater + +# ── the build script ── + + +def _script() -> str: + """The script build_frontend hands to bash, with npm stubbed out.""" + captured = {} + + async def _fake_run(*args, **kwargs): + captured["script"] = args[2] + return 0, "" + + with ( + patch.object(updater, "_run_cmd", _fake_run), + patch.object(updater, "npm_deps_stale", AsyncMock(return_value=False)), + patch.object(updater.os.path, "isdir", lambda p: True), + ): + asyncio.run(updater.build_frontend("a", "b")) + return captured["script"] + + +def test_the_build_never_writes_into_the_directory_being_served(): + script = _script() + assert "--outDir dist.new" in script + # The bare form is what emptied dist in place. + assert "npm run build\n" not in script + assert script.count("npm run build") == 1 + + +def test_the_swap_keeps_the_previous_bundle_as_a_rollback(): + script = _script() + # Order matters: the old bundle is only moved aside once the build has won. + assert script.index("npm run build") < script.index("mv dist dist.old") + assert script.index("mv dist dist.old") < script.index("mv dist.new dist") + + +def test_a_stale_scratch_dir_is_cleared_before_building(): + """A dist.new left by an interrupted run must not be built on top of.""" + assert "rm -rf dist.new" in _script() + + +# ── failure messages that name the actual failure ── + + +@pytest.mark.parametrize( + "rc, expected", + [ + (137, "memory limit"), + (-9, "memory limit"), + (124, "timed out"), + (90, "npm ci` failed"), + ], +) +def test_the_common_build_failures_are_named(rc, expected): + message = updater._explain_build_failure(rc, "some output") + assert expected in message + # And each says the bundle survived, which is the thing the operator + # actually needs to know. + assert "not touched" in message + + +def test_an_unremarkable_failure_is_passed_through_unchanged(): + assert updater._explain_build_failure(1, "tsc error TS2304") == "tsc error TS2304" + + +# ── boot-time recovery from an interrupted swap ── + + +def test_an_interrupted_swap_is_adopted_at_boot(tmp_path): + (tmp_path / "dist.old").mkdir() + (tmp_path / "dist.old" / "index.html").write_text("old") + (tmp_path / "dist.new").mkdir() + (tmp_path / "dist.new" / "index.html").write_text("new") + + _adopt_orphaned_bundle(tmp_path / "dist") + + # dist.new won the build; it is the newer of the two. + assert (tmp_path / "dist" / "index.html").read_text() == "new" + + +def test_only_a_rollback_bundle_is_still_adopted(tmp_path): + (tmp_path / "dist.old").mkdir() + (tmp_path / "dist.old" / "index.html").write_text("old") + + _adopt_orphaned_bundle(tmp_path / "dist") + + assert (tmp_path / "dist" / "index.html").read_text() == "old" + + +def test_adoption_does_nothing_when_a_bundle_is_already_in_place(tmp_path): + (tmp_path / "dist").mkdir() + (tmp_path / "dist" / "index.html").write_text("live") + (tmp_path / "dist.old").mkdir() + (tmp_path / "dist.old" / "index.html").write_text("old") + + _adopt_orphaned_bundle(tmp_path / "dist") + + assert (tmp_path / "dist" / "index.html").read_text() == "live" + + +def test_adoption_is_a_no_op_with_nothing_to_adopt(tmp_path): + _adopt_orphaned_bundle(tmp_path / "dist") + assert not (tmp_path / "dist").exists() + + +# ── docker steps get the same treatment (H2, P3) ── + + +def test_a_docker_timeout_says_retrying_is_safe(): + """1800s exists so a hung pull cannot wedge the update; hitting it said nothing. + + Both compose steps are idempotent, so the honest answer is "run it again" — + but the bare "Timed out after 1800s" left the operator guessing whether a + half-rebuilt stack was safe to touch. + """ + message = updater._explain_docker_failure( + 124, "Timed out after 1800s: docker compose pull", "docker compose pull" + ) + assert "idempotent" in message + assert "safe" in message + # And the platforms where the ceiling is actually reachable. + assert "macOS" in message and "WSL2" in message + + +def test_an_ordinary_docker_failure_is_passed_through_unchanged(): + assert ( + updater._explain_docker_failure(1, "no such image", "docker compose pull") + == "no such image" + ) + + +# ── what a browser gets mid-swap ── + + +def test_a_missing_bundle_answers_503_rather_than_an_opaque_500(): + """The window the swap narrowed, and what is served inside it. + + ``index.html`` absent is a real state: a build is mid-swap, or one failed. + Starlette raises inside ``FileResponse`` on a missing path, which reaches + the browser as a 500 indistinguishable from a crashed backend — so a + reader reloading during a build was told Condor was broken. 503 says the + true thing, and says the API is fine. + """ + dist = Path(web_app.__file__).resolve().parent.parent.parent / "frontend" / "dist" + index = dist / "index.html" + assets = dist / "assets" + + made_dist = not dist.is_dir() + made_assets = not assets.is_dir() + if made_dist: + dist.mkdir(parents=True) + if made_assets: + # StaticFiles refuses to mount a directory that is not there. + assets.mkdir(parents=True) + moved = index.exists() + aside = dist / "index.html.pytest-aside" + if moved: + index.rename(aside) + try: + with TestClient(create_app()) as client: + res = client.get("/settings") + assert res.status_code == 503, "a missing bundle is not a server error" + assert "rebuilt" in res.text + assert "API is unaffected" in res.text + # And it must not be cached, or the browser keeps the outage after the + # build finishes. + assert "no-cache" in res.headers.get("cache-control", "") + finally: + if moved: + aside.rename(index) + if made_assets: + assets.rmdir() + if made_dist: + dist.rmdir() + + +# ── and the other place that builds it ── + + +def test_make_build_frontend_swaps_too_rather_than_emptying_in_place(): + """`make run` and `make restart` stop Condor first; this target does not. + + It is public, and a Condor started any other way — ``run-fg``, a + supervisor, a second checkout — is still serving out of ``dist`` while it + runs. An in-place build would empty that directory under it, which is + exactly C1, reintroduced by the path the updater does not take. + """ + makefile = (Path(__file__).resolve().parent.parent / "Makefile").read_text() + target = makefile.split("\nbuild-frontend:", 1)[1].split("\n\n", 1)[0] + + assert "--outDir dist.new" in target, "builds straight into the served dir" + assert "rm -rf dist.new" in target, "an interrupted run left a stale scratch dir" + assert target.index("mv dist dist.old") < target.index("mv dist.new dist") diff --git a/tests/test_update_git_guards.py b/tests/test_update_git_guards.py new file mode 100644 index 000000000..977b3d8c3 --- /dev/null +++ b/tests/test_update_git_guards.py @@ -0,0 +1,208 @@ +"""Two things the shared git machinery could not see (S1, S3). + +Both fire once per checkout, so both components inherit them. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor.updates import components +from utils import updater + + +def _git(*args, cwd): + subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + env={ + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", + }, + ) + + +@pytest.fixture +def repo(tmp_path): + """A throwaway checkout with three commits.""" + d = tmp_path / "repo" + d.mkdir() + _git("init", "-q", "-b", "main", cwd=d) + for i in range(3): + (d / "f.txt").write_text(f"{i}\n") + _git("add", "-A", cwd=d) + _git("commit", "-qm", f"c{i}", cwd=d) + return d + + +@pytest.mark.asyncio +async def test_an_attached_checkout_is_not_detached(repo): + assert await updater.is_detached(str(repo)) is False + + +@pytest.mark.asyncio +async def test_a_detached_checkout_is_detected(repo): + """``--abbrev-ref`` cannot answer this — it returns the string ``HEAD``.""" + _git("checkout", "-q", "--detach", "HEAD~2", cwd=repo) + + assert await updater.get_current_branch(str(repo)) == "HEAD" + assert await updater.is_detached(str(repo)) is True + + +@pytest.mark.asyncio +async def test_a_detached_checkout_blocks_the_update(repo, monkeypatch): + """And it blocks before anything reads a branch name or fetches.""" + _git("checkout", "-q", "--detach", "HEAD~2", cwd=repo) + component = components.Component( + key="condor", name="Condor", repo_dir=str(repo), service=None + ) + + async def _never(*a, **k): # pragma: no cover - asserts it is not reached + raise AssertionError("fetched despite a detached HEAD") + + monkeypatch.setattr(updater, "fetch", _never) + + blocks = await components.repo_blocks(component) + assert [b.code for b in blocks] == ["detached-head"] + assert blocks[0].resolutions == ["cancel"] + # The message has to name the commit, or there is nothing to go back to. + short = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=repo, + capture_output=True, + text=True, + ).stdout.strip() + assert short in blocks[0].message + + +@pytest.mark.asyncio +async def test_a_stale_index_lock_gets_a_hint(repo): + """The raw git error sends the reader hunting a process that is not there.""" + (repo / ".git" / "index.lock").touch() + + rc, out = await updater._run_git("add", "-A", repo_dir=str(repo)) + assert rc != 0 + assert "index.lock" in out + assert "Stale git lock" in out + assert str(repo) in out + + +@pytest.mark.asyncio +async def test_the_hint_is_not_appended_to_unrelated_failures(repo): + rc, out = await updater._run_git("rev-parse", "nope-not-a-ref", repo_dir=str(repo)) + assert rc != 0 + assert "Stale git lock" not in out + + +@pytest.mark.asyncio +async def test_the_remote_default_branch_is_read_not_assumed(repo, tmp_path): + """A clone knows its remote's default branch; a bare repo with no origin does not.""" + clone = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(repo), str(clone)], check=True, capture_output=True + ) + assert await updater.remote_default_branch(str(clone)) == "main" + # No origin at all -> "cannot tell", never a guess. + assert await updater.remote_default_branch(str(repo)) is None + + +# ── A branch that was never pushed is not an unreachable remote ── + + +@pytest.fixture +def cloned(tmp_path): + """A checkout with a real ``origin`` that answers — a local bare repo.""" + origin = tmp_path / "origin.git" + seed = tmp_path / "seed" + seed.mkdir() + _git("init", "-q", "-b", "main", cwd=seed) + (seed / "f.txt").write_text("1\n") + _git("add", "-A", cwd=seed) + _git("commit", "-qm", "c0", cwd=seed) + _git("clone", "-q", "--bare", str(seed), str(origin), cwd=tmp_path) + + work = tmp_path / "work" + _git("clone", "-q", str(origin), str(work), cwd=tmp_path) + return work + + +@pytest.mark.asyncio +async def test_a_local_only_branch_is_named_as_such_not_blamed_on_the_network(cloned): + """The ordinary state of anyone on a feature branch they have not pushed. + + git exits 128 both for "no such ref upstream" and for "cannot reach the + remote", so the guard reported an unpushed branch as an unreachable + remote — and sent the reader to debug a network that was fine. + """ + _git("checkout", "-q", "-b", "feat/mine", cwd=cloned) + + component = components.Component("condor", "Condor", str(cloned)) + blocks = await components.repo_blocks(component) + + assert [b.code for b in blocks] == ["no-upstream-branch"] + message = blocks[0].message + assert "feat/mine" in message + assert "unreachable" not in message.lower() + # And it says what to do about it, naming the branch that does track. + assert "main" in message + + +@pytest.mark.asyncio +async def test_a_tracked_branch_still_passes_the_guard(cloned): + """The fix must not turn a healthy checkout into a blocked one.""" + component = components.Component("condor", "Condor", str(cloned)) + assert await components.repo_blocks(component) == [] + + +@pytest.mark.asyncio +async def test_the_status_card_says_why_it_cannot_compare(cloned): + """``Failed to fetch from remote`` was the only thing the card ever said.""" + _git("checkout", "-q", "-b", "feat/mine", cwd=cloned) + + info = await updater.check_for_updates(repo_dir=str(cloned)) + + assert info["error"] and "not on origin" in info["error"] + + +def test_a_genuine_transport_failure_is_not_mistaken_for_a_missing_branch(): + assert updater.is_missing_remote_ref("fatal: couldn't find remote ref feat/x") + assert updater.is_missing_remote_ref("fatal: could not find remote ref feat/x") + assert not updater.is_missing_remote_ref( + "fatal: unable to access 'https://...': Could not resolve host: github.com" + ) + assert not updater.is_missing_remote_ref("") + + +@pytest.mark.asyncio +async def test_a_default_branch_with_a_slash_keeps_its_prefix(cloned, tmp_path): + """`rsplit("/", 1)` on the ref silently renames `fix/x` to `x`. + + Branch names contain slashes routinely. Taking the last segment made + `image_tracks_this_checkout` compare `fix/x != x` and skip the image with + a wrong reason, and put a branch that does not exist into the advice + `no-upstream-branch` prints. + """ + _git("checkout", "-q", "-b", "fix/slashed", cwd=cloned) + _git("push", "-q", "origin", "fix/slashed", cwd=cloned) + # Point the remote's HEAD at the slashed branch, as `remote set-head` does. + _git( + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/fix/slashed", + cwd=cloned, + ) + + assert await updater.remote_default_branch(str(cloned)) == "fix/slashed" + + +@pytest.mark.asyncio +async def test_no_remote_head_is_unknown_not_a_guess(cloned): + _git("symbolic-ref", "-d", "refs/remotes/origin/HEAD", cwd=cloned) + assert await updater.remote_default_branch(str(cloned)) is None diff --git a/tests/test_update_journal.py b/tests/test_update_journal.py index 30a9ab8e4..a28a42089 100644 --- a/tests/test_update_journal.py +++ b/tests/test_update_journal.py @@ -328,21 +328,41 @@ def test_the_plan_is_laid_out_before_anything_runs(_a_sibling_api): "hummingbot-api.up", "hummingbot-api.health", "condor.fast-forward", + "condor.unstash", "condor.deps", "condor.frontend", + "condor.doctor", ] assert all(s.state == "pending" for s in steps) assert "Pulling" in steps[1].label -def test_source_mode_builds_instead_of_pulling(_a_sibling_api): +def test_an_image_this_checkout_does_not_track_gets_no_image_steps(_a_sibling_api): + """The plan must not promise a pull the run is going to skip. + + Off the default branch the published tag has nothing to do with the code on + disk, so the image is the operator's and is left alone. The checkout is + still fast-forwarded -- it supplies the shared files the container reads. + """ status = components.ComponentStatus( - key=components.HUMMINGBOT_API, name="x", facets={}, mode="source" + key=components.HUMMINGBOT_API, + name="x", + facets={ + "image": components.Facet( + kind="image", + current="sha256:abc", + behind=0, + detail=["Left alone; you own this image."], + ) + }, + mode="image", ) steps = run_module._plan( [components.HUMMINGBOT_API], {components.HUMMINGBOT_API: status} ) - assert "Rebuilding" in steps[1].label + labels = [s.label for s in steps] + assert labels == ["Fast-forwarding the hummingbot-api repo"] + assert not any("Pulling" in label for label in labels) # --------------------------------------------------------------------------- @@ -447,7 +467,7 @@ def test_a_condor_update_never_restarts_the_process(): lives in the helper because every test here depends on it. """ run = _run_condor_update(before=BEFORE, after=AFTER) - assert [s.key for s in run.steps][-1] == "condor.frontend" + assert [s.key for s in run.steps][-1] == "condor.doctor" assert run.target_commit == AFTER diff --git a/tests/test_update_preflight.py b/tests/test_update_preflight.py index 31b0f560c..716d95d8f 100644 --- a/tests/test_update_preflight.py +++ b/tests/test_update_preflight.py @@ -45,6 +45,7 @@ def _blocks( with patch.multiple( "utils.updater", is_git_repo=AsyncMock(return_value=is_repo), + is_detached=AsyncMock(return_value=False), fetch=AsyncMock(return_value=(fetched, "" if fetched else "no such remote")), get_current_branch=AsyncMock(return_value="main"), ahead_count=AsyncMock(return_value=ahead), @@ -60,6 +61,7 @@ def test_the_blockers_are_computed_against_a_freshly_fetched_origin(): with patch.multiple( "utils.updater", is_git_repo=AsyncMock(return_value=True), + is_detached=AsyncMock(return_value=False), fetch=fetch, get_current_branch=AsyncMock(return_value="main"), ahead_count=AsyncMock(return_value=0), @@ -238,13 +240,36 @@ def test_the_real_hummingbot_api_tree_blocks_on_at_most_environment_yml(): _DEFAULT_SERVICE = {"image": "hummingbot/hummingbot-api:latest"} -def _facet(*, service=_DEFAULT_SERVICE, local=LOCAL, remote=REMOTE): - """``service=None`` stands for ``docker compose config`` having failed.""" +IMAGE_ID = "sha256:943773e318e028d8a303e7c2237586fbd0d96e3ba46b902b215dc3799264bb54" + + +def _facet( + *, + service=_DEFAULT_SERVICE, + local=LOCAL, + remote=REMOTE, + image_id=IMAGE_ID, + published=True, +): + """``service=None`` stands for ``docker compose config`` having failed. + + ``image_id=None`` is "no such image here"; ``local=None`` with an id is an + image that is present but carries no registry digest — a local build under + the classic image store. + + ``published`` is what the registry says about the *local* digest, and it is + stubbed rather than left to the real call: ``registry_has_digest`` shells + out to ``docker buildx imagetools inspect``, so an unstubbed facet test + reached the network, took around thirteen seconds, and agreed with its own + assertion only because the registry happened to answer one of the two ways + that lead to the same branch. + """ with patch.multiple( "utils.updater", compose_service=AsyncMock(return_value=service), - local_image_digest=AsyncMock(return_value=local), + local_image_identity=AsyncMock(return_value=(image_id, local)), registry_image_digest=AsyncMock(return_value=remote), + registry_has_digest=AsyncMock(return_value=published), ): return asyncio.run(components._image_facet("/tmp/repo", "hummingbot-api")) @@ -265,6 +290,43 @@ def test_a_newer_published_image_is_reported_as_behind(): assert facet.behind == 1 +def test_an_image_the_registry_never_published_is_the_operators_own(): + """A `make build` over the published tag, as the containerd store leaves it. + + Same tag, a RepoDigest like any other, and a digest the registry has never + heard of. Offering to pull here would overwrite the only copy of a build + that exists nowhere else. + """ + facet, _ = _facet(published=False) + assert facet.up_to_date is True + assert facet.behind == 0 + assert facet.available is None + assert facet.error is None + assert any("Built locally" in d for d in facet.detail) + + +def test_an_inconclusive_origin_probe_offers_nothing_and_says_why(): + """Neither "behind" nor "built here" — the probe failed, so neither is known. + + The two wrong answers are not symmetric: one overwrites the operator's + build, the other hides a real update. This blocks instead, which is what an + ``error`` on the image facet does. + """ + facet, _ = _facet(published=None) + assert facet.behind == 0, "an unknown origin is not evidence of being behind" + assert facet.up_to_date is False + assert facet.error_code == "image-origin-unknown" + assert facet.error and "either" in facet.error + + +def test_a_digest_the_registry_does_publish_is_simply_behind(): + """The ordinary case, pinned explicitly rather than by what the network did.""" + facet, _ = _facet(published=True) + assert facet.up_to_date is False + assert facet.behind == 1 + assert facet.error is None + + def test_an_unreachable_registry_never_claims_up_to_date(): facet, _ = _facet(remote=None) assert facet.up_to_date is False @@ -272,18 +334,42 @@ def test_an_unreachable_registry_never_claims_up_to_date(): assert facet.available is None -def test_an_image_with_no_local_digest_is_unknown_not_behind(): - """Loaded from a tarball: there is nothing comparable to compare.""" - facet, _ = _facet(local=None) +def test_an_image_that_is_not_here_at_all_is_unknown_not_behind(): + """Nothing to compare, and nothing to run either.""" + facet, _ = _facet(local=None, image_id=None) assert facet.up_to_date is False assert facet.current == "unknown" - assert facet.error and "never been pulled" in facet.error + assert facet.error_code == "image-absent" + assert facet.error and "never been pulled or built" in facet.error + +def test_an_image_present_without_a_registry_digest_is_the_operators_own(): + """`make build` under the classic image store leaves `RepoDigests` empty. -def test_a_build_key_means_source_mode_and_no_registry_lookup(): - facet, mode = _facet(service={"build": {"context": "."}, "image": "local/api"}) - assert mode == "source" + That store is still the default on Docker Engine, so this is the ordinary + shape of an operator running their own build — not an error, and above all + not a reason to block the whole update. It used to raise + ``registry-unreachable`` while the registry was answering fine. + """ + facet, _ = _facet(local=None) + assert facet.error is None, "a local build is not a failure" assert facet.up_to_date is True + assert facet.behind == 0 + assert facet.current == "sha256:943773e3" + assert facet.detail and "Built here" in facet.detail[0] + + +def test_a_hand_added_build_key_no_longer_means_a_second_mode(): + """ "source" was a mode the product could not enter, so it is gone. + + No shipped compose file has a ``build:`` key and hummingbot-api is deployed + from the published image, so the branch was dead code. An operator who adds + one is running their own image, which the checkout gate and the + locally-built check already cover -- by leaving it alone, not by rebuilding + it on their behalf. + """ + _, mode = _facet(service={"build": {"context": "."}, "image": "local/api"}) + assert mode == "image" def test_docker_being_down_is_an_error_not_a_verdict(): @@ -296,3 +382,122 @@ def test_docker_being_down_is_an_error_not_a_verdict(): @pytest.mark.parametrize("digest", ["", None, "not-a-digest"]) def test_short_digest_survives_junk(digest): assert components._short_digest(digest) in ("", "not-a-digest") + + +# ── V12: an update must not adopt another checkout's stack ── + + +def _owner_probe(monkeypatch, label, repo_dir): + """Stub `docker inspect` returning the compose working-dir label.""" + from utils import updater as u + + async def fake(*args, **kwargs): + return (0, label) if label is not None else (1, "") + + monkeypatch.setattr(u, "_run_cmd", fake) + return asyncio.run(u.compose_stack_owner(repo_dir, "hummingbot-api")) + + +def test_containers_from_another_checkout_are_reported(monkeypatch): + """`container_name:` is fixed, so Compose matches by name across projects. + + It does not treat that as an error — it prints `Recreate` and the second + checkout takes over the first's containers and its postgres volume. + Condor points at a directory; Compose acts on a project. + """ + owner = _owner_probe(monkeypatch, "/srv/other-checkout", "/srv/mine") + assert owner == "/srv/other-checkout" + + +def test_our_own_containers_are_not_a_conflict(monkeypatch): + assert _owner_probe(monkeypatch, "/srv/mine", "/srv/mine") is None + + +def test_a_trailing_slash_is_not_a_different_checkout(monkeypatch): + assert _owner_probe(monkeypatch, "/srv/mine/", "/srv/mine") is None + + +def test_nothing_running_is_not_a_conflict(monkeypatch): + assert _owner_probe(monkeypatch, None, "/srv/mine") is None + + +def test_an_unlabelled_container_is_not_a_conflict(monkeypatch): + """Docker prints `` for a label that is not set.""" + assert _owner_probe(monkeypatch, "", "/srv/mine") is None + + +# ── V13: a daemon that is down is not a missing image ── + + +def _facet_with_docker(*, daemon_up, identity=(None, None)): + """`_image_facet` with the daemon's liveness and the inspect result pinned.""" + with patch.multiple( + "utils.updater", + compose_service=AsyncMock(return_value=_DEFAULT_SERVICE), + local_image_identity=AsyncMock(return_value=identity), + registry_image_digest=AsyncMock(return_value=REMOTE), + docker_running=AsyncMock(return_value=daemon_up), + ): + return asyncio.run(components._image_facet("/tmp/repo", "hummingbot-api")) + + +def test_a_stopped_daemon_is_not_reported_as_a_missing_image(): + """Reported from a live dashboard while Docker Desktop was restarting. + + `docker image inspect` is the only probe here that needs the daemon — + `compose config` merges files locally and `imagetools inspect` asks the + registry — so a daemon that is down reaches the `local is None` branch + with everything else looking healthy. It read "it has never been pulled by + tag", about an image that was sitting on disk the whole time. + """ + facet, _ = _facet_with_docker(daemon_up=False) + assert facet.error_code == "docker-unavailable" + assert "Docker is not answering" in facet.error + assert "never been pulled" not in facet.error + # The registry answered even with the daemon down, so keep what we learned. + assert facet.available == "sha256:62d70399" + + +def test_a_running_daemon_with_no_such_image_still_says_so(): + """The fix must not swallow the case it was carved out of.""" + facet, _ = _facet_with_docker(daemon_up=True) + assert facet.error_code == "image-absent" + assert "never been pulled or built" in facet.error + + +def test_the_daemon_is_not_probed_when_the_image_is_there(): + """One extra command, and only on the error path.""" + + def boom(*a, **kw): # pragma: no cover - reached only on regression + raise AssertionError("docker_running must not run on the happy path") + + with patch.multiple( + "utils.updater", + compose_service=AsyncMock(return_value=_DEFAULT_SERVICE), + local_image_identity=AsyncMock(return_value=(IMAGE_ID, LOCAL)), + registry_image_digest=AsyncMock(return_value=LOCAL), + docker_running=boom, + ): + facet, _ = asyncio.run(components._image_facet("/tmp/repo", "hummingbot-api")) + assert facet.up_to_date is True and facet.error is None + + +def test_a_symlinked_checkout_is_not_a_foreign_stack(monkeypatch, tmp_path): + """Compose records the *resolved* directory; `normpath` does not resolve. + + A checkout reached through a symlink — macOS `/tmp` is one, and symlinked + project roots are common — compared unequal to itself, so the guard + refused the operator's own update naming the same directory back at them. + """ + from utils import updater as u + + real = tmp_path / "checkout" + real.mkdir() + link = tmp_path / "via-symlink" + link.symlink_to(real) + + async def fake(*args, **kwargs): + return (0, str(real)) + + monkeypatch.setattr(u, "_run_cmd", fake) + assert asyncio.run(u.compose_stack_owner(str(link), "hummingbot-api")) is None diff --git a/tests/test_update_restart_flow.py b/tests/test_update_restart_flow.py index 11f444a5a..19a78ff13 100644 --- a/tests/test_update_restart_flow.py +++ b/tests/test_update_restart_flow.py @@ -5,12 +5,11 @@ kept serving the previous ``frontend/dist``. These tests pin that the build runs exactly when it is needed. -The restart is now the admin's, not Condor's: the pipeline ends with the new -code on disk and a relaunch owed, because exec'ing a process that is almost -never the top of its own tree races whatever started it into a second copy. -What survives here is the manual path — ``request_restart`` is still reachable -from ``/update``'s own button, and it must still go through the normal shutdown -rather than a bare ``os.execv`` that would skip ``teardown()``. +The restart is the admin's, not Condor's: the pipeline ends with the new code +on disk and a relaunch owed, because the moment to interrupt a running bot is +not the pipeline's to pick. The restart itself is ordinary, and these pin that +it stays so — ``request_restart`` signals rather than exec'ing, so the shutdown +runs in full instead of a bare ``os.execv`` skipping ``teardown()``. The pipeline moved out of the Telegram handler and into :mod:`condor.updates.run` (FEAT-070), so these drive the engine instead — but they patch the same @@ -20,7 +19,7 @@ import asyncio import os import signal -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -78,6 +77,12 @@ def _stub_pipeline(**overrides): frontend_needs_build=AsyncMock(return_value=True), build_frontend=AsyncMock(return_value=(True, "built in 297ms")), get_current_branch=AsyncMock(return_value="main"), + # Both shell out for real if left alone -- `stash_pop` to git in this + # very checkout, `run_doctor` to `uv run python -m condor.doctor` -- so + # they are stubbed to defaults the pipeline reads as "nothing to do" + # and "healthy". The arms that matter get them overridden per test. + stash_pop=AsyncMock(return_value=(True, "Nothing of ours to restore.")), + run_doctor=AsyncMock(return_value=(True, "All checks passed.")), # Still patched, still never called: the assertion that it is not is the # point (see ``test_the_pipeline_ends_without_restarting_the_process``). request_restart=AsyncMock(), @@ -227,13 +232,23 @@ def _build_script(**stale): def test_the_build_installs_first_when_the_deps_are_stale(): - assert "npm ci && npm run build" in _build_script(return_value=True) + script = _build_script(return_value=True) + assert "npm ci" in script + assert script.index("npm ci") < script.index("npm run build") def test_the_build_skips_the_install_when_the_deps_are_current(): script = _build_script(return_value=False) assert "npm ci" not in script - assert script.endswith("npm run build") + assert "npm run build" in script + + +def test_the_build_never_targets_the_directory_being_served(): + """Both paths build into the scratch dir and swap; neither empties dist.""" + for stale in (True, False): + script = _build_script(return_value=stale) + assert "--outDir dist.new" in script + assert script.rstrip().endswith("mv dist.new dist") # --------------------------------------------------------------------------- @@ -268,6 +283,32 @@ def test_the_pipeline_ends_without_restarting_the_process(): ) +def test_an_unhappy_doctor_warns_without_failing_the_update(): + """The doctor reads the install; it does not get a vote on whether it landed. + + By the time it runs the code is on disk, dependencies are synced and the + bundle is rebuilt. Failing the run on its verdict would report all of that + as not having happened, and would withhold the relaunch that is the only + way to *reach* the new code — including the fix for whatever the doctor is + complaining about. So: warned, succeeded, and still owed. + """ + ok, run, _watcher, _steps = _run_condor( + run_doctor=AsyncMock(return_value=(False, "Chrome is not installed.")) + ) + assert ok is True + + doctor = next(s for s in run.steps if s.key.endswith(".doctor")) + assert doctor.state == update_run.WARNED + assert "Chrome" in (doctor.output_tail or "") + + assert not any(s.state == update_run.FAILED for s in run.steps) + assert run.error is None + + pending = update_run.relaunch_pending() + assert pending is not None, "a warned run still owes the relaunch" + assert pending["target_commit"] == "new_sha" + + def test_update_does_not_build_when_the_frontend_is_untouched(): ok, run, _watcher, steps = _run_condor( frontend_needs_build=AsyncMock(return_value=False) @@ -362,3 +403,76 @@ def test_hung_step_is_killed_rather_than_waited_on(): rc, output = asyncio.run(updater._run_cmd("bash", "-c", "sleep 30", timeout=0.5)) assert rc == 124 assert "Timed out" in output + + +# --------------------------------------------------------------------------- +# A restart is only offered when it can work (C2.1, C2.2) +# --------------------------------------------------------------------------- + + +def test_a_failed_run_is_not_offered_a_restart(): + """The button used to sit directly under "fix it before restarting". + + The keyboard's condition was ``run.state == "failed" or + relaunch_pending()``, so *any* failure produced a Restart Now -- including + one whose own message forbids restarting. Pressing it booted the new code + against the old venv. + """ + from handlers.admin import update as update_handler + + run = update_run.Run( + id="r1", started=0.0, actor={}, components=["condor"], state="failed" + ) + with patch.object(update_handler.updates, "relaunch_pending", lambda: None): + keyboard = update_handler._run_keyboard(run) + + labels = [b.text for row in keyboard.inline_keyboard for b in row] + assert "Restart Now" not in labels + assert labels == ["Back"] + + +def test_a_pending_relaunch_is_still_offered_a_restart(): + """The condition that was always right is untouched.""" + from handlers.admin import update as update_handler + + run = update_run.Run( + id="r1", started=0.0, actor={}, components=["condor"], state="succeeded" + ) + with patch.object( + update_handler.updates, "relaunch_pending", lambda: {"target_commit": "abc1234"} + ): + keyboard = update_handler._run_keyboard(run) + + labels = [b.text for row in keyboard.inline_keyboard for b in row] + assert "Restart Now" in labels + + +def test_a_failed_exec_leaves_a_record(tmp_path, monkeypatch): + """Otherwise the reason the restart died goes with the tmux pane.""" + monkeypatch.setenv("CONDOR_RUNTIME_ROOT", str(tmp_path)) + monkeypatch.setattr(updater, "set_tmux_remain_on_exit", lambda on: None) + monkeypatch.setattr( + updater.os, "execv", Mock(side_effect=OSError(8, "Exec format error")) + ) + + with pytest.raises(OSError): + updater.exec_restart() + + record = tmp_path / "restart-failure.log" + assert record.exists(), "a failed exec left nothing behind" + body = record.read_text() + assert "Exec format error" in body + assert "exec " in body + + +def test_the_pane_is_kept_alive_across_the_handover(tmp_path, monkeypatch): + """A successor that fails to boot must not close the pane with the traceback.""" + monkeypatch.setenv("CONDOR_RUNTIME_ROOT", str(tmp_path)) + calls = [] + monkeypatch.setattr(updater, "set_tmux_remain_on_exit", lambda on: calls.append(on)) + monkeypatch.setattr(updater.os, "execv", Mock(side_effect=OSError("nope"))) + + with pytest.raises(OSError): + updater.exec_restart() + + assert calls == [True], "remain-on-exit was not enabled before the exec" diff --git a/tests/test_update_run_lock.py b/tests/test_update_run_lock.py new file mode 100644 index 000000000..65132436f --- /dev/null +++ b/tests/test_update_run_lock.py @@ -0,0 +1,88 @@ +"""The advisory lock names who holds it (C16). + +``start`` refuses a second run on the same checkout and says which process is +already updating. It could only ever say that if the pid survives being read +by the process that lost the race. +""" + +from __future__ import annotations + +import fcntl +import os + +import pytest + +from condor.paths import runtime_root +from condor.updates.run import _acquire_run_lock, _release_run_lock + + +def test_the_loser_of_the_race_can_still_read_the_holders_pid(): + """Opening ``"w"`` truncated the file before ``flock`` was even attempted. + + So the contender erased the pid on its way to being refused, ``start`` + read an empty file, and the "(pid N)" clause was dropped from every + conflict message — and anyone inspecting ``update.lock`` afterwards found + it empty too. + """ + handle = _acquire_run_lock() + assert handle is not None, "nothing else should hold the lock in a test" + path = runtime_root() / "update.lock" + try: + assert path.read_text().strip() == str(os.getpid()) + + # Exactly what a second process does: open the same path, then fail to + # take the lock. The open must not destroy what the holder wrote. + contender = path.open("a+") + try: + raised = False + try: + fcntl.flock(contender.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raised = True + assert raised, "the contender should not have got the lock" + finally: + contender.close() + + assert path.read_text().strip() == str( + os.getpid() + ), "the contender's open truncated the holder's pid" + finally: + _release_run_lock(handle) + + +def test_the_lock_is_rewritten_rather_than_appended(): + """``"a+"`` must not leave a run's pid stacked under the previous one.""" + first = _acquire_run_lock() + assert first is not None + _release_run_lock(first) + second = _acquire_run_lock() + assert second is not None + try: + assert (runtime_root() / "update.lock").read_text().strip() == str(os.getpid()) + finally: + _release_run_lock(second) + + +def test_a_failure_before_the_task_exists_releases_the_lock(): + """`_execute`'s finally is the only release, and there is no `_execute` yet. + + `check()` shells out to git and docker, so it can raise for reasons that + have nothing to do with this checkout — and the lock was then held by a + process that was not updating, locking out every other process on the + checkout until it exited. + """ + import asyncio + from unittest.mock import AsyncMock, patch + + from condor.updates import run as run_module + + with patch.object( + run_module.components, "check", AsyncMock(side_effect=OSError("no git")) + ): + with pytest.raises(OSError): + asyncio.run(run_module.start(["condor"])) + + assert run_module._lock_handle is None, "the lock outlived the failed setup" + # And the next attempt is not refused by our own stale handle. + assert _acquire_run_lock() is not None + _release_run_lock(_acquire_run_lock()) diff --git a/tests/test_web_servers_parallel_status.py b/tests/test_web_servers_parallel_status.py index cbed37118..a26567bcf 100644 --- a/tests/test_web_servers_parallel_status.py +++ b/tests/test_web_servers_parallel_status.py @@ -7,7 +7,6 @@ """ import asyncio -import time import pytest from fastapi import FastAPI @@ -44,13 +43,26 @@ def get_chat_default_server(self, chat_id): return self._default +#: How long a fetch waits for its siblings before deciding they are not coming. +#: Only ever reached by a *serial* implementation, so it bounds the failure +#: rather than the success — a concurrent one releases the moment the last +#: fetch arrives, however loaded the machine is. +RENDEZVOUS_TIMEOUT = 5.0 + + class FakeSDS: - """Cache always misses; each fetch sleeps like a connect timeout would.""" + """Cache always misses; each fetch sleeps like a connect timeout would. + + ``rendezvous`` makes the fan-out provable instead of merely likely: every + fetch waits until that many are in flight at once, which a serial resolver + can never satisfy. + """ - def __init__(self, statuses, delay=DELAY, raises=()): + def __init__(self, statuses, delay=DELAY, raises=(), rendezvous=0): self._statuses = statuses self._delay = delay self._raises = set(raises) + self._barrier = asyncio.Barrier(rendezvous) if rendezvous else None self.concurrent = 0 self.max_concurrent = 0 @@ -62,7 +74,10 @@ async def get_or_fetch(self, name, data_type, **params): self.concurrent += 1 self.max_concurrent = max(self.max_concurrent, self.concurrent) try: - await asyncio.sleep(self._delay) + if self._barrier is not None: + await asyncio.wait_for(self._barrier.wait(), RENDEZVOUS_TIMEOUT) + else: + await asyncio.sleep(self._delay) if name in self._raises: raise RuntimeError("connection refused") return self._statuses[name] @@ -87,25 +102,33 @@ def three_servers(): } -def test_three_slow_servers_resolve_in_one_timeout_not_three(monkeypatch): - """Two unreachable servers must not stack their timeouts on the third.""" +def test_three_slow_servers_are_all_in_flight_at_once(monkeypatch): + """Two unreachable servers must not stack their timeouts on the third. + + Asserted by rendezvous rather than by the clock. The wall-clock form + (``elapsed < DELAY * 1.5``) measured the machine as much as the code: it + passed alone and failed under a loaded full-suite run, which is the worst + kind of red — it says nothing about the property and trains people to + re-run. Here every fetch blocks until all three have arrived, so a serial + resolver cannot reach the assertion at all and a slow one still can. + """ sds = FakeSDS( statuses={ "alpha": {"status": "online"}, "bravo": None, "charlie": None, - } + }, + rendezvous=3, ) client = build_client(monkeypatch, FakeConfigManager(three_servers()), sds) - t0 = time.monotonic() resp = client.get("/servers") - elapsed = time.monotonic() - t0 assert resp.status_code == 200 - # Serial would be ~3x DELAY; concurrent stays well under 1.5x the slowest. - assert elapsed < DELAY * 1.5, f"took {elapsed:.2f}s, expected ~{DELAY:.2f}s" - assert sds.max_concurrent == 3 + assert sds.max_concurrent == 3, ( + "the three fetches never overlapped — they were resolved one after " + "another, so the endpoint pays the sum of the connect timeouts" + ) def test_all_online_response_is_unchanged(monkeypatch): diff --git a/tests/test_web_updates_routes.py b/tests/test_web_updates_routes.py index 2ba0fee46..584b6dab0 100644 --- a/tests/test_web_updates_routes.py +++ b/tests/test_web_updates_routes.py @@ -10,6 +10,7 @@ """ import asyncio +from unittest.mock import patch import pytest from starlette.testclient import TestClient @@ -29,6 +30,11 @@ ("post", "/api/v1/updates/start", {"components": ["condor"]}), ("get", "/api/v1/updates/run", None), ("post", "/api/v1/updates/dismiss", {"run_id": "u-1"}), + # Last in the list and first in consequence: this one restarts the process. + # It answers 409 for an admin with nothing pending, which is why it belongs + # here rather than only in the relaunch tests — the gate runs first, so the + # refusal a non-admin gets is 403 either way. + ("post", "/api/v1/updates/relaunch", None), ] @@ -83,7 +89,7 @@ def clean_run_state(): @pytest.mark.parametrize("method,path,body", ROUTES) def test_every_route_refuses_a_non_admin(as_user, admin, method, path, body): - """A logged-in non-admin gets 403 from all six, not just the mutating ones.""" + """A logged-in non-admin gets 403 from every one, not just the mutating ones.""" client = as_user(999) res = getattr(client, method)(path, **({"json": body} if body else {})) assert res.status_code == 403, f"{method} {path} let a non-admin through" @@ -413,3 +419,64 @@ def test_routes_module_shells_out_to_nothing(): # The one thing it does import for update knowledge is the engine. assert "condor" in imported + + +# --------------------------------------------------------------------------- +# Relaunch: the verb the Local surface was missing (C3) +# --------------------------------------------------------------------------- + + +def test_relaunch_restarts_when_something_is_pending(as_user, admin): + """Telegram has always had this button; the dashboard now makes the same call.""" + called = [] + with patch.object( + updates, "relaunch_pending", lambda: {"target_commit": "abc1234"} + ): + with patch.object(updates, "request_relaunch", lambda: called.append(True)): + response = as_user(admin, "admin").post("/api/v1/updates/relaunch") + + assert response.status_code == 202 + assert response.json()["relaunching"] is True + assert called == [True], "the restart was never requested" + + +def test_relaunch_refuses_when_there_is_nothing_to_apply(as_user, admin): + called = [] + with patch.object(updates, "relaunch_pending", lambda: None): + with patch.object(updates, "request_relaunch", lambda: called.append(True)): + response = as_user(admin, "admin").post("/api/v1/updates/relaunch") + + assert response.status_code == 409 + assert called == [], "a restart was requested with nothing pending" + + +def test_the_403_names_the_role_as_config_yml_spells_it(monkeypatch): + """`get_user_role` returns the enum, and interpolating it renders + "UserRole.USER" — a string that appears nowhere in config.yml, so the + reader cannot search for it and the value they are told to change looks + nothing like the one on disk.""" + from enum import Enum + + from fastapi import HTTPException + + from condor.web import auth as auth_module + + class UserRole(str, Enum): + USER = "user" + + class FakeCM: + def is_admin(self, _uid): + return False + + def get_user_role(self, _uid): + return UserRole.USER + + monkeypatch.setattr(auth_module, "get_config_manager", lambda: FakeCM()) + + with pytest.raises(HTTPException) as exc: + auth_module.require_admin( + auth_module.WebUser(id=7, username="u", first_name="U", role="user") + ) + + assert "'user'" in exc.value.detail + assert "UserRole" not in exc.value.detail diff --git a/utils/updater.py b/utils/updater.py index 497f46ec2..f63a3b3b2 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -18,9 +18,12 @@ import json import logging import os +import shutil import signal +import subprocess import sys from dataclasses import dataclass +from pathlib import Path logger = logging.getLogger(__name__) @@ -75,9 +78,22 @@ async def _run_cmd( return proc.returncode, output +# A crashed git leaves .git/index.lock behind and every later subcommand fails +# on it. The raw error does reach output_tail, but it says "Another git process +# seems to be running" -- which sends the reader looking for a process that is +# not there. One line naming the file turns a dead end into a fix. +_LOCK_HINT = ( + "\nStale git lock: remove {repo}/.git/index.lock if no git process is " + "actually running." +) + + async def _run_git(*args: str, repo_dir: str = CONDOR_DIR) -> tuple[int, str]: """Run a git command in the given repo and return (returncode, stdout).""" - return await _run_cmd("git", *args, cwd=repo_dir, timeout=GIT_TIMEOUT) + rc, out = await _run_cmd("git", *args, cwd=repo_dir, timeout=GIT_TIMEOUT) + if rc != 0 and "index.lock" in out: + out += _LOCK_HINT.format(repo=repo_dir) + return rc, out async def get_local_commit(repo_dir: str = CONDOR_DIR) -> str: @@ -93,11 +109,56 @@ async def get_local_commit_full(repo_dir: str = CONDOR_DIR) -> str: async def get_current_branch(repo_dir: str = CONDOR_DIR) -> str: - """Return the current branch name.""" + """Return the current branch name, or the literal ``HEAD`` when detached. + + ``rev-parse --abbrev-ref`` answers ``HEAD`` for a detached checkout rather + than failing, and every caller downstream then treats that string as a + branch name. Use :func:`is_detached` to ask the question this cannot + answer. + """ _, out = await _run_git("rev-parse", "--abbrev-ref", "HEAD", repo_dir=repo_dir) return out +async def is_detached(repo_dir: str = CONDOR_DIR) -> bool: + """Whether ``HEAD`` points at a commit rather than a branch. + + ``symbolic-ref`` is the only probe that distinguishes the two: + ``rev-parse --abbrev-ref HEAD`` returns the *string* ``HEAD`` when detached, + which reads as an ordinary branch name everywhere downstream. That is how a + detached checkout came to fast-forward onto the remote's default branch -- + ``ahead_count(repo, "HEAD")`` resolves ``origin/HEAD``, finds nothing ahead, + so no ``diverged`` block fires, and ``git merge --ff-only origin/HEAD`` + quietly succeeds and leaves the checkout detached. + """ + rc, _ = await _run_git("symbolic-ref", "-q", "HEAD", repo_dir=repo_dir) + return rc != 0 + + +async def remote_default_branch(repo_dir: str = CONDOR_DIR) -> str | None: + """The remote's default branch (e.g. ``main``), or ``None`` if unknown. + + Read from ``refs/remotes/origin/HEAD`` rather than hardcoded, so a repo that + renames its default branch does not need a code change here. ``None`` when + the ref is absent -- a clone made with ``--single-branch``, or one that has + never run ``git remote set-head`` -- and callers must treat that as "cannot + tell" rather than assuming anything. + """ + rc, out = await _run_git( + "symbolic-ref", "-q", "refs/remotes/origin/HEAD", repo_dir=repo_dir + ) + if rc != 0 or not out: + return None + # refs/remotes/origin/main -> main, and refs/remotes/origin/fix/x -> fix/x. + # Not rsplit("/", 1): a branch name may contain slashes, and taking the last + # segment silently renames `fix/x` to `x` -- which made the comparison in + # image_tracks_this_checkout never match on such a default, and put a branch + # that does not exist into the advice `no-upstream-branch` prints. + prefix = "refs/remotes/origin/" + ref = out.strip() + return ref[len(prefix) :] if ref.startswith(prefix) else None + + async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: """ Fetch from remote and compare local vs remote HEAD. @@ -126,9 +187,14 @@ async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: result["branch"] = branch # Fetch latest from remote - rc, _ = await _run_git("fetch", "origin", branch, repo_dir=repo_dir) + rc, fetch_out = await _run_git("fetch", "origin", branch, repo_dir=repo_dir) if rc != 0: - result["error"] = "Failed to fetch from remote" + result["error"] = ( + f"`{branch}` is not on origin, so there is nothing to compare " + "against — push it, or switch to a branch that tracks origin." + if is_missing_remote_ref(fetch_out) + else "Failed to fetch from remote" + ) return result # Get local and remote commits @@ -205,6 +271,24 @@ def _lines(output: str) -> tuple[str, ...]: return tuple(line.strip() for line in (output or "").split("\n") if line.strip()) +#: What git says when the branch simply is not on the remote. Distinct from +#: every other fetch failure: the network is fine, the remote answered, and it +#: does not have this ref. Both spellings appear across git versions. +_MISSING_REMOTE_REF = ("couldn't find remote ref", "could not find remote ref") + + +def is_missing_remote_ref(output: str) -> bool: + """Whether a failed fetch means "no such branch upstream", not "no remote". + + git exits 128 for both, so the caller that cannot tell them apart reports + an unpushed local branch -- the ordinary state of anyone working on a + feature -- as an unreachable remote, and sends the operator to check their + network for a problem that is not there. + """ + lowered = (output or "").lower() + return any(marker in lowered for marker in _MISSING_REMOTE_REF) + + async def fetch(repo_dir: str = CONDOR_DIR) -> tuple[bool, str]: """Update ``origin/`` without touching the working tree.""" branch = await get_current_branch(repo_dir) @@ -356,12 +440,15 @@ async def discard_paths(repo_dir: str, paths: list[str]) -> tuple[bool, str]: ) if rc != 0: return False, f"Could not restore {len(to_checkout)} file(s):\n{out}" - messages.append(f"Restored {len(to_checkout)} tracked file(s).") + messages.append( + f"Discarded your changes to {len(to_checkout)} tracked file(s); " + "they are back to the committed version." + ) if to_clean: rc, out = await _run_git("clean", "-fd", "--", *to_clean, repo_dir=repo_dir) if rc != 0: return False, f"Could not remove {len(to_clean)} file(s):\n{out}" - messages.append(f"Removed {len(to_clean)} untracked file(s).") + messages.append(f"Deleted {len(to_clean)} untracked file(s).") return True, " ".join(messages) or "Nothing to discard." @@ -389,6 +476,257 @@ async def stash_paths(repo_dir: str, paths: list[str]) -> tuple[bool, str]: return True, out or "Stashed." +STASH_MESSAGE = "condor /update" + + +async def update_stash_ref(repo_dir: str = CONDOR_DIR) -> str | None: + """``stash@{n}`` of the newest stash *we* made, or ``None``. + + Addressed by name, never by position. ``stash apply``/``drop`` default to + ``stash@{0}``, and the stack reorders every time anyone stashes anything -- + so "a condor stash exists somewhere" plus "act on the top one" applied and + then **dropped** the operator's own stash, restored unrelated work into the + tree nobody asked for, left ours parked, and reported success. + """ + rc, listing = await _run_git( + "stash", "list", "--format=%gd%x00%gs", repo_dir=repo_dir + ) + if rc != 0: + return None + for line in listing.splitlines(): + ref, _, subject = line.partition("\0") + if STASH_MESSAGE in subject: + return ref.strip() + return None + + +async def has_update_stash(repo_dir: str = CONDOR_DIR) -> bool: + """Whether work parked by a previous update is still sitting in a stash.""" + return await update_stash_ref(repo_dir) is not None + + +async def _stash_paths_at(repo_dir: str, ref: str = "stash@{0}") -> list[str]: + """Every path a stash entry would write, tracked and untracked alike. + + ``stash show --name-only`` covers the tracked half. Files stashed with + ``-u`` live in a third parent commit it does not look at, so they need + asking for separately or the abort below would leave them behind. + """ + paths: list[str] = [] + rc, out = await _run_git("stash", "show", "--name-only", ref, repo_dir=repo_dir) + if rc == 0: + paths.extend(_lines(out)) + rc, out = await _run_git( + "ls-tree", "-r", "--name-only", f"{ref}^3", repo_dir=repo_dir + ) + if rc == 0: + paths.extend(_lines(out)) + seen: dict[str, None] = {} + for path in paths: + seen.setdefault(path, None) + return list(seen) + + +async def _undo_partial_apply(repo_dir: str, paths: list[str]) -> bool: + """Put ``paths`` back to HEAD after an apply that conflicted. Nothing else. + + ``reset --hard`` is the obvious abort and is *wrong here*: these checkouts + are runtime working directories, which is the whole reason the preflight + gates on the intersection of dirty and incoming rather than on "is the tree + clean". Unrelated uncommitted work is expected, and a hard reset would + destroy it to tidy up a failure that never touched it. + + So undo exactly the stash's own path set. A path git knows goes back to + HEAD, index and worktree both, which clears the unmerged entry and the + conflict markers with it. A path that was untracked when it was stashed has + no HEAD version to go back to, so it is removed -- the copy in the stash is + the one that survives either way. + """ + ok = True + for rel in paths: + rc, _ = await _run_git("cat-file", "-e", f"HEAD:{rel}", repo_dir=repo_dir) + if rc == 0: + rc, _ = await _run_git( + "checkout", "-f", "HEAD", "--", rel, repo_dir=repo_dir + ) + else: + rc, _ = await _run_git("rm", "-f", "--quiet", "--", rel, repo_dir=repo_dir) + if rc != 0: + target = Path(repo_dir) / rel + try: + target.unlink(missing_ok=True) + rc = 0 + except OSError: + pass + if rc != 0: + ok = False + if not ok: + return False + # Only an empty unmerged set proves the abort landed; anything left would + # be the half-merged tree this exists to prevent, reported as prevented. + rc, out = await _run_git( + "diff", "--name-only", "--diff-filter=U", repo_dir=repo_dir + ) + return rc == 0 and not out.strip() + + +async def stash_pop(repo_dir: str) -> tuple[bool, str]: + """Put back the work ``stash_paths`` parked, if it still applies cleanly. + + Stashing was never meant to be the end of the story -- the whole point of + parking work rather than discarding it is getting it back, and a clean + restore is the common case once the fast-forward has landed. + + The failure is the part that needs care, and it is not rare: ``stash`` is + offered as a way out of ``dirty-conflict``, which by definition means the + incoming commits touch the very files being parked. So the apply conflicts + on the main line, not in some corner. + + ``git stash pop`` handles that badly for an unattended caller. It writes + conflict markers into the tracked file, leaves the index unmerged, keeps + the stash, and exits non-zero -- so the update ends with a checkout nobody + can build from, an agent playbook full of ``<<<<<<<``, and a suggestion to + run the command that just failed (it fails again: "needs merge"). Hence + ``apply`` and an explicit drop: apply can be undone, pop cannot. + """ + ref = await update_stash_ref(repo_dir) + if ref is None: + return True, "Nothing of ours was stashed." + + paths = await _stash_paths_at(repo_dir, ref) + rc, out = await _run_git("stash", "apply", ref, repo_dir=repo_dir) + if rc == 0: + # Same ref, and nothing between the two can renumber it: ``apply`` does + # not pop, and this is the only thing touching the stack right now. + await _run_git("stash", "drop", ref, repo_dir=repo_dir) + return True, "Restored the work that was stashed before the update." + + undone = await _undo_partial_apply(repo_dir, paths) + named = ", ".join(paths[:5]) + ("…" if len(paths) > 5 else "") + # Name the ref we actually resolved. Ours is not necessarily on top -- any + # stash pushed after it sits above -- so bare `git stash pop` would restore + # somebody else's work, which is the mistake this function stopped making + # two lines up. + if undone: + return False, ( + f"Your work does not apply on top of the update: {named}.\n\n" + "Nothing was lost and nothing is half-merged — the checkout was put " + f"back the way the update left it, and your work is still in {ref}. " + "Bring it back when you can resolve it by hand:\n" + f" git stash pop {ref}\n" + "That will reproduce the conflict, this time where you can see it." + ) + return False, ( + f"{out}\n\nYour work is still in {ref} — nothing was lost — but the " + "checkout could not be put back cleanly and may hold conflict markers. " + "Check `git status` before running anything from it." + ) + + +async def move_to_local_root(repo_dir: str, rel_paths: list[str]) -> tuple[bool, str]: + """Move edited agent files into the gitignored local root, then reset stock. + + The non-lossy exit from a ``dirty-conflict``. FEAT-115 redirects writes made + *through the product* into ``.condor/agents``, where they shadow stock per + item and no update can touch them -- but a text editor knows nothing about + that, and these are markdown files sitting in a git checkout, which is + exactly what people open in one. Such an edit blocked the update, and the + only one-click way out destroyed it. + + This puts the edit where the product would have put it: the customization + survives and keeps being used, the tracked file fast-forwards normally, and + the install ends up in the state FEAT-115 supports rather than a dead end. + """ + from condor.paths import local_agents_root + + if not rel_paths: + return True, "Nothing to move." + + local_root = local_agents_root() + # ``agents/scout/AGENT.md`` -> ``/scout/AGENT.md`` + plan = [ + (rel, local_root / Path(rel).relative_to("agents")) + for rel in rel_paths + if (Path(repo_dir) / rel).is_file() + ] + + # Every destination is checked before anything moves. A conflict found + # half way through a batch used to return "nothing was moved" with the + # earlier files already gone -- the checkout left showing a deletion + # nobody made, and the update still blocked. + clashes = [rel for rel, target in plan if target.exists()] + if clashes: + # The product already has its own version of these, and the checkout + # has a second, separately made edit. ``shutil.move`` would replace + # the first with the second: one customization destroyed while the + # message says both were kept. Only the operator can say which they + # meant, so nothing is touched. + named = ", ".join(clashes) + return False, ( + f"You already have your own version of {named} under " + f"{local_root}, and the copy in the checkout has been edited " + "separately. Keeping the checkout's version would overwrite the " + "one already in use, so nothing was moved — compare them and " + "delete the one you do not want, then run the update again." + ) + + moved: list[str] = [] + for rel, target in plan: + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(Path(repo_dir) / rel), str(target)) + except (OSError, ValueError) as exc: + return False, f"Could not move {rel}: {exc}" + moved.append(rel) + + if not moved: + return True, "Nothing to move." + + # The tracked copies go back to HEAD so the fast-forward is unobstructed. + # Only the tracked ones: `keep-mine` is offered for an `untracked-conflict` + # too, and `checkout HEAD -- ` fails on a path HEAD has never heard + # of -- which reported the whole resolution as failed after it had in fact + # moved the file. An untracked path needs no reset; moving it *was* the + # resolution. + tracked: list[str] = [] + staged_adds: list[str] = [] + for rel in moved: + rc, _ = await _run_git("cat-file", "-e", f"HEAD:{rel}", repo_dir=repo_dir) + if rc == 0: + tracked.append(rel) + continue + # Not in HEAD, but it may still be in the *index* as a staged + # addition. ``checkout HEAD --`` cannot touch that, so the entry + # survived the move and the incoming commit that adds the same path + # still had something to collide with -- reported as a success. + rc, _ = await _run_git( + "ls-files", "--error-unmatch", "--", rel, repo_dir=repo_dir + ) + if rc == 0: + staged_adds.append(rel) + if tracked: + rc, out = await _run_git("checkout", "HEAD", "--", *tracked, repo_dir=repo_dir) + if rc != 0: + return False, ( + f"Moved your edits, but could not reset the tracked files:\n{out}" + ) + if staged_adds: + rc, out = await _run_git( + "rm", "--cached", "--quiet", "--", *staged_adds, repo_dir=repo_dir + ) + if rc != 0: + return False, ( + f"Moved your edits, but could not clear the staged " + f"addition(s):\n{out}" + ) + + return True, ( + f"Kept your version of {len(moved)} file(s), moved into .condor/agents " + "where updates leave them alone. The shipped copies will now " + "fast-forward; your versions stay in use." + ) + + async def install_dependencies() -> tuple[bool, str]: """Run uv sync to install any new dependencies, then bring the Claude bridge current.""" rc, output = await _run_cmd("uv", "sync", cwd=CONDOR_DIR, timeout=DEPS_TIMEOUT) @@ -489,6 +827,68 @@ async def npm_deps_stale(old_commit: str = "", new_commit: str = "") -> bool: ) +def _explain_build_failure(rc: int, output: str) -> str: + """Turn a build exit code into something the operator can act on. + + Three failures are common, look identical in the raw output, and have + different remedies — so each gets named rather than all three arriving as + "Frontend build failed". + """ + base = output or "Frontend build failed (no output)" + if "__CONDOR_NPM_CI_FAILED__" in base or rc == 90: + return ( + base.replace("__CONDOR_NPM_CI_FAILED__", "").strip() + + "\n\n`npm ci` failed, and it removes node_modules before it " + "installs — so there is currently no dependency tree and the build " + "could not have run. Fix the network or free some disk, then " + "`cd frontend && npm ci`. The bundle on disk was not touched." + ) + # 137 is SIGKILL, which for a vite build is almost always the memory + # ceiling: WSL2 caps the VM by default and Docker Desktop caps it on macOS. + # The process gets no chance to say so, so the output is unhelpfully empty. + if rc in (137, -9): + return ( + base + "\n\nThe build was killed (signal 9), which for a bundler is " + "almost always the memory limit. On WSL2 raise `memory=` in " + "`.wslconfig`; on macOS raise Docker Desktop's memory. The bundle " + "on disk was not touched." + ) + if rc == 124: + return ( + base + "\n\nThe build timed out. On a checkout under /mnt/c on WSL2, or " + "on Docker Desktop for macOS, this step can legitimately take " + "several times longer than it does on Linux. Retrying is safe — " + "`npm ci` and the build are both idempotent, and the bundle on " + "disk was not touched." + ) + return base + + +async def run_doctor() -> tuple[bool, str]: + """Run Condor's own health check and report what it said. + + ``python -m condor.doctor`` already exists, is read-only, and exits non-zero + on real failures -- and was wired only into ``make install``. Nothing in the + update path ran it, so a broken boot was indistinguishable from a good one + until somebody opened the dashboard. The contrast is the sharpest argument + for it: the hummingbot-api path gets a health gate whose step is literally + "wait for the API to answer", while Condor -- the component actually running + the update -- got none. + + Never fatal. It runs after the code is already on disk, so failing the run + on its verdict would report a completed update as a failed one. + """ + script = ( + 'export NVM_DIR="$HOME/.nvm"; ' + '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; ' + "uv run python -m condor.doctor" + ) + rc, output = await _run_cmd( + "bash", "-c", script, cwd=CONDOR_DIR, timeout=DEPS_TIMEOUT + ) + return rc == 0, output or ("Doctor reported no output." if rc == 0 else "") + + async def build_frontend( old_commit: str = "", new_commit: str = "" ) -> tuple[bool, str]: @@ -503,17 +903,35 @@ async def build_frontend( if not os.path.isdir(FRONTEND_DIR): return True, "No frontend directory; skipped." - install = "npm ci && " if await npm_deps_stale(old_commit, new_commit) else "" + stale = await npm_deps_stale(old_commit, new_commit) + # `npm ci` deletes node_modules before it installs, so a registry blip or a + # full disk part-way through leaves the install with no dependency tree at + # all -- and then the build cannot run either. Say which of the two failed, + # because the remedies are different. + install = ( + 'npm ci || { echo "__CONDOR_NPM_CI_FAILED__"; exit 90; }; ' if stale else "" + ) + # Build into a scratch directory and swap. vite's defaults are + # `outDir: "dist"` with `emptyOutDir: true`, so building in place emptied + # the directory uvicorn was serving out of and every request during the + # build returned an error -- and a build that then *failed* left the install + # with no dashboard at all, while the run reported that the previous bundle + # would come back. Exposure is now one rename, and dist.old is a free + # instant rollback. script = ( 'export NVM_DIR="$HOME/.nvm"; ' '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; ' - 'cd "$1" || exit 1; ' + install + "npm run build" + 'cd "$1" || exit 1; ' + install + "rm -rf dist.new && " + "npm run build -- --outDir dist.new --emptyOutDir && " + "rm -rf dist.old && " + "{ [ -d dist ] && mv dist dist.old || true; } && " + "mv dist.new dist" ) rc, output = await _run_cmd( "bash", "-c", script, "bash", FRONTEND_DIR, timeout=FRONTEND_BUILD_TIMEOUT ) if rc != 0: - return False, output or "Frontend build failed (no output)" + return False, _explain_build_failure(rc, output) return True, output @@ -545,6 +963,34 @@ def restart_pending() -> bool: return _restart_pending +def set_tmux_remain_on_exit(on: bool) -> None: + """Toggle ``remain-on-exit`` on Condor's own tmux session, best effort. + + The Makefile turns this on for the startup probe and back off once the boot + is confirmed, deliberately: a crash three hours later must still take the + session down, or ``make status`` would report a dead Condor as running. That + leaves an in-process restart with no such protection — the successor's own + startup failure closes the pane and takes the traceback with it. + + So the same dance is repeated around the exec: on just before, off again + once the new process has got far enough to serve. Silent when there is no + tmux, which is the ordinary case for a foreground or containerized run. + """ + session = os.environ.get("CONDOR_TMUX_SESSION", "condor") + value = "on" if on else "off" + for scope in ("set-option", "set-window-option"): + try: + result = subprocess.run( + ["tmux", scope, "-t", session, "remain-on-exit", value], + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return + if result.returncode == 0: + return + + def exec_restart() -> None: """Replace this process with a fresh one. Never returns. @@ -561,7 +1007,40 @@ def exec_restart() -> None: pass # sys.argv[0] is usually the relative "main.py"; make sure it still resolves. os.chdir(CONDOR_DIR) - os.execv(python, [python] + sys.argv) + # Keep the pane alive across the handover so the successor's own startup + # failure is readable. main() turns it back off once it is serving. + set_tmux_remain_on_exit(True) + try: + os.execv(python, [python] + sys.argv) + except OSError: + # A failed exec is the one way a restart can end with nothing running + # and nothing said. The process image is still this one, but the event + # loop is gone and the tmux pane is about to close, so a log line alone + # can vanish with it -- hence a file as well, in the runtime root where + # the next boot (and the operator) can find it. + logger.exception("exec_restart failed; Condor is not coming back") + _record_restart_failure() + raise + + +def _record_restart_failure() -> None: + """Leave the reason a restart died somewhere it will survive the pane.""" + import traceback + from datetime import datetime, timezone + + try: + from condor.paths import runtime_root + + path = runtime_root() / "restart-failure.log" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"{datetime.now(timezone.utc).isoformat()}\n" + f"exec {sys.executable} {' '.join(sys.argv)}\n\n" + f"{traceback.format_exc()}", + encoding="utf-8", + ) + except Exception: # noqa: BLE001 - never mask the original failure + logger.debug("Could not write restart-failure.log", exc_info=True) # --------------------------------------------------------------------------- @@ -601,27 +1080,79 @@ async def compose_service(repo_dir: str, service: str) -> dict | None: return definition if isinstance(definition, dict) else None -async def compose_mode(repo_dir: str, service: str) -> str: - """How this install produces ``service``: ``source``, ``image`` or ``unknown``. +async def docker_running() -> bool: + """Whether the Docker daemon answers at all. + + Needed because nothing else in this module can stand in for it, which is + how a stopped daemon came to be reported as a missing image. Measured with + Docker Desktop mid-restart: + + * ``docker compose config`` exits **0** -- it merges files locally and + never opens the socket, so the "is Docker running?" guard on the compose + probe does not fire; + * ``docker buildx imagetools inspect`` exits **0** -- it asks the registry + directly, so the *available* digest still resolves; + * ``docker image inspect`` is the only one that needs the daemon, so it + alone fails -- and its failure used to be read as "this image has never + been pulled". + + The result: a daemon that was merely restarting produced "it has never + been pulled by tag", about an image sitting on disk the whole time. + """ + rc, _ = await _run_cmd( + "docker", "version", "--format", "{{.Server.Version}}", timeout=20 + ) + return rc == 0 + + +async def local_image_identity(image_ref: str) -> tuple[str | None, str | None]: + """``(image id, registry digest)`` for the local copy of ``image_ref``. - A ``build:`` key means the image is built here, so an update is - ``compose build``. No ``build:`` key means a published image is pulled, so - an update is ``compose pull`` -- and the git checkout, whatever it says, - has nothing to do with the version running inside the container. + Two questions that had been collapsed into one, and they have different + answers on the two image stores Docker ships. + + ``RepoDigests[0]`` is index-level when the image was pulled by tag, which + makes it directly comparable to what the registry reports. But a *locally + built* image has one only under the containerd image store; under the + classic (overlay2) store -- still the default on Docker Engine, which is + where this runs in production -- ``RepoDigests`` is simply ``[]``. So + "no registry digest" does not mean "no image": it is exactly the shape an + operator's own ``make build`` leaves behind. + + The image id answers "is it here at all", because ``docker image inspect`` + fails outright when it is not. ``(None, None)`` therefore means absent, + and ``(id, None)`` means present with no registry identity -- built here, + or loaded from a tarball. Neither is "behind". """ - definition = await compose_service(repo_dir, service) - if definition is None: - return "unknown" - return "source" if definition.get("build") else "image" + rc, out = await _run_cmd( + "docker", + "image", + "inspect", + image_ref, + "--format", + "{{.Id}}\t{{json .RepoDigests}}", + timeout=30, + ) + if rc != 0 or not out: + return None, None + image_id, _, raw = out.partition("\t") + image_id = image_id.strip() or None + try: + digests = json.loads(raw) + except ValueError: + return image_id, None + if not isinstance(digests, list) or not digests: + return image_id, None + first = str(digests[0]) + return image_id, (first.split("@", 1)[1] if "@" in first else None) async def local_image_digest(image_ref: str) -> str | None: """The registry digest of the local copy of ``image_ref``, or None. - ``RepoDigests[0]`` is index-level when the image was pulled by tag, which - makes it directly comparable to what the registry reports. An image loaded - from a tarball has no RepoDigest at all -- that is "unknown", never - "behind". + Kept as the narrow question for callers that only want the comparable + digest; :func:`local_image_identity` is the one that can also say whether + the image is present at all. """ rc, out = await _run_cmd( "docker", @@ -668,24 +1199,105 @@ async def registry_image_digest(image_ref: str) -> str | None: return digest if digest.startswith("sha256:") else None +async def registry_has_digest(image_ref: str, digest: str) -> bool | None: + """Whether the registry holds ``digest`` for ``image_ref``'s repository. + + The only reliable way to tell an operator's own build from a published + image. The obvious test -- "a built image has no ``RepoDigests``" -- does + **not** work: Docker's containerd image store records a canonical digest for + a build as well as for a pull, so a locally built image has a RepoDigest + like any other, it simply is not one the registry has ever seen. Measured on + 29.8.1: pull, then ``docker build`` over the same tag, and RepoDigests is + populated both times with different values. ``.Comment`` is no help either + (both say ``buildkit.dockerfile.v0``, because the published image was itself + built by buildkit in CI). + + Returns ``None`` when the question could not be answered, which callers must + keep distinct from ``False`` -- an unreachable registry is not evidence that + an image was built locally. + """ + repo = image_ref.split("@", 1)[0].rsplit(":", 1)[0] + rc, out = await _run_cmd( + "docker", + "buildx", + "imagetools", + "inspect", + f"{repo}@{digest}", + "--format", + "{{.Manifest.Digest}}", + timeout=30, + ) + if rc == 0 and (out or "").strip().startswith("sha256:"): + return True + # "not found" is an answer; anything else is a failure to ask. + if "not found" in (out or "").lower(): + return False + return None + + +def _explain_docker_failure(rc: int, output: str, what: str) -> str: + """Turn a docker step's exit code into something the operator can act on. + + The 1800s ceiling exists so a hung pull cannot wedge the update forever, but + hitting it returned a bare "Timed out after 1800s" and left the operator to + guess whether a half-rebuilt stack was safe to touch. It is: ``compose pull`` + and ``compose up -d`` are both idempotent, so the answer is simply to run the + update again. + """ + base = output or f"{what} failed (no output)" + if rc == 124: + return ( + base + f"\n\nThe step timed out. `{what}` is idempotent, so re-running " + "the update is safe and will pick up wherever it got to. On Docker " + "Desktop for macOS, or on WSL2 with the daemon behind a VM, a cold " + "pull can legitimately take several times longer than it does on " + "Linux." + ) + return base + + async def compose_pull(repo_dir: str, service: str) -> tuple[bool, str]: """Pull the published image for one service.""" rc, output = await _run_cmd( "docker", "compose", "pull", service, cwd=repo_dir, timeout=DOCKER_TIMEOUT ) if rc != 0: - return False, output or "docker compose pull failed (no output)" + return False, _explain_docker_failure(rc, output, "docker compose pull") return True, output -async def compose_build(repo_dir: str, service: str) -> tuple[bool, str]: - """Rebuild one service's image from source.""" - rc, output = await _run_cmd( - "docker", "compose", "build", service, cwd=repo_dir, timeout=DOCKER_TIMEOUT +async def compose_stack_owner(repo_dir: str, service: str) -> str | None: + """The checkout the running containers were deployed from, or ``None``. + + Compose records the project's working directory on every container it + creates, which is the only thing that can answer "is this stack ours". + + It has to be asked. ``container_name:`` is fixed for every service in + hummingbot-api's compose file, so two checkouts are one stack as far as + Docker is concerned: Compose matches the existing containers by name, + reports ``Recreate``, and the second checkout takes over the first one's + containers *and its postgres volume*. No error, no warning. The Makefile + gained a guard for ``make deploy``; an update never goes through the + Makefile, so it needs the same question asked here. + + ``None`` means nothing is running under that name, or Docker could not be + asked -- neither of which is evidence of a conflict. + """ + rc, out = await _run_cmd( + "docker", + "inspect", + service, + "--format", + '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}', + timeout=30, ) - if rc != 0: - return False, output or "docker compose build failed (no output)" - return True, output + owner = (out or "").strip() + if rc != 0 or not owner or owner == "": + return None + # ``realpath``, not ``normpath``: Compose records the *resolved* directory, + # and a checkout reached through a symlink (macOS ``/tmp`` is one) would + # otherwise compare unequal to itself and block the operator's own update. + return owner if os.path.realpath(owner) != os.path.realpath(repo_dir) else None async def compose_up(repo_dir: str) -> tuple[bool, str]: @@ -694,7 +1306,7 @@ async def compose_up(repo_dir: str) -> tuple[bool, str]: "docker", "compose", "up", "-d", cwd=repo_dir, timeout=DOCKER_TIMEOUT ) if rc != 0: - return False, output or "docker compose up failed (no output)" + return False, _explain_docker_failure(rc, output, "docker compose up -d") return True, output diff --git a/uv.lock b/uv.lock index e41d6e639..3ffc340da 100644 --- a/uv.lock +++ b/uv.lock @@ -1,16 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.12, <3.14" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -95,40 +89,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] [[package]] @@ -250,14 +210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/ea/52b0fc6f69432c7bf3f5fbe6f707113650aa40a1a05b9096ffc2bba4f77d/av-17.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffaf266a1a9c2148072de0a4b5ae98061465178d2cfaa69ee089761149342974", size = 37444817, upload-time = "2026-03-14T14:38:18.563Z" }, { url = "https://files.pythonhosted.org/packages/34/ad/d2172966282cb8f146c13b6be7416efefde74186460c5e1708ddfc13dba6/av-17.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:45a35a40b2875bf2f98de7c952d74d960f92f319734e6d28e03b4c62a49e6f49", size = 28888553, upload-time = "2026-03-14T14:38:21.223Z" }, { url = "https://files.pythonhosted.org/packages/b0/bb/c5a4c4172c514d631fb506e6366b503576b8c7f29809cf42aca73e28ff01/av-17.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:3d32e9b5c5bbcb872a0b6917b352a1db8a42142237826c9b49a36d5dbd9e9c26", size = 21916910, upload-time = "2026-03-14T14:38:23.706Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8e/c40ac08e63f79387c59f6ecc38f47d4c942b549130eee579ec1a91f6a291/av-17.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:d13250fb4b4522e9a6bec32da082556d5f257110ea223758151375748d9bbe25", size = 23483029, upload-time = "2026-03-14T14:38:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/a9/fb/b4419494bfc249163ec393c613966d66db7e95c76da3345711cd115a79df/av-17.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:dbb56aa3b7ae72451d1bf6e9d37c7d83d39b97af712f73583ff419fbf08fc237", size = 18920446, upload-time = "2026-03-14T14:38:27.905Z" }, - { url = "https://files.pythonhosted.org/packages/30/62/c2306d91602ddad2c56106f21dcb334fd51d5ea2e952f7fa025bb8aa39fc/av-17.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a213ac9e83b7ab12c2e9f277a09cac8e9d85cf0883efdab7a87a60e2e4e48879", size = 37477266, upload-time = "2026-03-14T14:38:30.404Z" }, - { url = "https://files.pythonhosted.org/packages/28/cd/c8510a9607886785c0b3ca019d503e888c3757529be42a7287fe2bfa92d5/av-17.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e15c88bb0921f9435bcc5a27a0863dba571a80ad5e1389c4fcf2073833bb4a74", size = 39572988, upload-time = "2026-03-14T14:38:32.984Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2d/207d9361e25b5abec9be335bbab4df6b6b838e2214be4b374f4cfb285427/av-17.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:096cfd1e9fc896506726c7c42aaf9b370e78c2f257cde4d6ddb6c889bfcc49ec", size = 38399591, upload-time = "2026-03-14T14:38:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/73/ca/307740c6aa2980966bf11383ffcb04bacc5b13f3d268ab4cfb274ad6f793/av-17.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3649ab3d2c7f58049ded1a36e100c0d8fd529cf258f41dd88678ba824034d8c9", size = 40590681, upload-time = "2026-03-14T14:38:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/35/f2/6fdb26d0651adf409864cb2a0d60da107e467d3d1aabc94b234ead54324a/av-17.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e5002271ab2135b551d980c2db8f3299d452e3b9d3633f24f6bb57fffe91cd10", size = 29216337, upload-time = "2026-03-14T14:38:40.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" }, ] [[package]] @@ -306,11 +258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, ] @@ -386,10 +333,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, - { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -435,28 +378,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -506,38 +427,6 @@ wheels = [ { 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" }, ] @@ -571,14 +460,14 @@ name = "cohere" version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastavro", marker = "sys_platform != 'emscripten'" }, - { name = "httpx", marker = "sys_platform != 'emscripten'" }, - { name = "pydantic", marker = "sys_platform != 'emscripten'" }, - { name = "pydantic-core", marker = "sys_platform != 'emscripten'" }, - { name = "requests", marker = "sys_platform != 'emscripten'" }, - { name = "tokenizers", marker = "sys_platform != 'emscripten'" }, - { name = "types-requests", marker = "sys_platform != 'emscripten'" }, - { name = "typing-extensions", marker = "sys_platform != 'emscripten'" }, + { name = "fastavro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "types-requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/e2/d38ef3a50a0b2bc75868df26ad0590ace76669559bd13c0f0edf412ca4c6/cohere-6.0.0.tar.gz", hash = "sha256:d08319957bc6f61c3c07f129299773868c0c6796786c9bb940ac8dcfcebbb3d5", size = 194128, upload-time = "2026-04-08T22:32:52.005Z" } wheels = [ @@ -610,8 +499,7 @@ dependencies = [ { name = "markdown" }, { name = "matplotlib" }, { name = "mcp" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, { name = "pandas-ta" }, { name = "plotly" }, { name = "pydantic-ai" }, @@ -714,28 +602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] [[package]] @@ -760,19 +626,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, - { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, - { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, @@ -808,16 +661,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/03/c0db0a5276599fb44ceafa2f2cb1afd5628808ec406fe036060a39693680/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:393a9e7e989034660526a2c0e8bb65d1924f43d9a5c77d336494a353d16ba2a4", size = 16860452, upload-time = "2026-02-04T06:11:52.276Z" }, { url = "https://files.pythonhosted.org/packages/0b/03/4e3728ce29d192ee75ed9a2d8589bf4f19edafe5bed3845187de51b179a3/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a3d0682f2b9082e31c73d75b45f16cde77355ab76d7e8356a24c3cb2480a6d3", size = 38995174, upload-time = "2026-02-04T06:11:55.477Z" }, { url = "https://files.pythonhosted.org/packages/9b/15/6e8e87c6a201d69803a79ac2e29623ce7c2cc9cd1df9db99810cca714373/ctranslate2-4.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:baa6d2b10f57933d8c11791e8522659217918722d07bbef2389a443801125fe7", size = 18844953, upload-time = "2026-02-04T06:11:58.519Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/8a6b7ba18cad0c8667ee221ddab8c361cb70926440e5b8dd0e81924c28ac/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5dfb076566551f4959dfd0706f94c923c1931def9b7bb249a2caa6ab23353a0", size = 1257560, upload-time = "2026-02-04T06:12:00.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/c2/8817ca5d6c1b175b23a12f7c8b91484652f8718a76353317e5919b038733/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:eecdb4ed934b384f16e8c01b185b082d6b5ffc7dcbb0b6a6eb48cd465282d957", size = 11918995, upload-time = "2026-02-04T06:12:02.875Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/b8eb3acc67bbca4d9872fc9ff94db78e6167a7ba5cd932f585d1560effc7/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aa6796edcc3c8d163c9e39c429d50076d266d68980fed9d1b2443f617c67e9e", size = 16844162, upload-time = "2026-02-04T06:12:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/80/11/6474893b07121057035069a0a483fe1cd8c47878213f282afb4c0c6fc275/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24c0482c51726430fb83724451921c0e539d769c8618dcfd46b1645e7f75960d", size = 38966728, upload-time = "2026-02-04T06:12:07.923Z" }, - { url = "https://files.pythonhosted.org/packages/94/88/8fc7ff435c5e783e5fad9586d839d463e023988dbbbad949d442092d01f1/ctranslate2-4.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:76db234c0446a23d20dd8eeaa7a789cc87d1d05283f48bf3152bae9fa0a69844", size = 19100788, upload-time = "2026-02-04T06:12:10.592Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b3/f100013a76a98d64e67c721bd4559ea4eeb54be3e4ac45f4d801769899af/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:058c9db2277dc8b19ecc86c7937628f69022f341844b9081d2ab642965d88fc6", size = 1280179, upload-time = "2026-02-04T06:12:12.596Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b77f748015667a5e2ca54a5ee080d7016fce34314f0e8cf904784549305a/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5abcf885062c7f28a3f9a46be8d185795e8706ac6230ad086cae0bc82917df31", size = 11940166, upload-time = "2026-02-04T06:12:14.054Z" }, - { url = "https://files.pythonhosted.org/packages/7d/78/6d7fd52f646c6ba3343f71277a9bbef33734632949d1651231948b0f0359/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9950acb04a002d5c60ae90a1ddceead1a803af1f00cadd9b1a1dc76e1f017481", size = 16849483, upload-time = "2026-02-04T06:12:17.082Z" }, - { url = "https://files.pythonhosted.org/packages/40/27/58769ff15ac31b44205bd7a8aeca80cf7357c657ea5df1b94ce0f5c83771/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dcc734e92e3f1ceeaa0c42bbfd009352857be179ecd4a7ed6cccc086a202f58", size = 38949393, upload-time = "2026-02-04T06:12:21.302Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" }, ] [[package]] @@ -842,15 +685,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/72/1732a24ef4a2aeba994b80ec163debe8deda403c07e4abbc0443bca078b8/curl_cffi-0.16.3-cp310-abi3-win_amd64.whl", hash = "sha256:fe87b66e324ed7318166698e02169f3208dbda32b872a27d2bc61a9c19b335eb", size = 1978602, upload-time = "2026-09-02T11:58:00.033Z" }, { url = "https://files.pythonhosted.org/packages/45/bb/67bec3132aeabac99dfe2f299a9b43dcb5de23ad96219ee98516d177fc9c/curl_cffi-0.16.3-cp310-abi3-win_arm64.whl", hash = "sha256:5a2ba880019f9e5a9e8f38ae22de6e4ea4c8d34a51ae4f1a2fce962c7b632006", size = 1713140, upload-time = "2026-09-02T11:58:01.558Z" }, { url = "https://files.pythonhosted.org/packages/49/e3/b88f9b1a60a1e29b42e9371c1b3f4fdd83bf8177fcf863df67438da12693/curl_cffi-0.16.3-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:01c31369b1c8063c7e459152c508c90de7a4218aa66ee3a1f575ae37ce44bc5a", size = 8607348, upload-time = "2026-09-02T11:58:03.095Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f4/3dedff1a31c93a9b18acaa346e23832c29bc18075138e90e9af795188e5e/curl_cffi-0.16.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:0c8b70191dc88ea770a5c39d7e213bff1606e248c13566777e6527f0d8cf96ec", size = 8607323, upload-time = "2026-09-02T11:58:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/73/b7/99708ed83c11132ec0311a28ed46fe1cd10e8cb6ecd3c82f01f1f80c3c2c/curl_cffi-0.16.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8055ec9d7c15237747be254739c40057e3684f56854e95c12aaf3c95838ba2d6", size = 3026149, upload-time = "2026-09-02T11:58:06.973Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d5/6c0400fb64097c4662da4e5d2d1e7daa8027d1431b1c8880c0f8f2051ae1/curl_cffi-0.16.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:391096e903ec98b909bb355e008ec7c211d710b6a23663a7f1f10aa54a027538", size = 2784153, upload-time = "2026-09-02T11:58:08.726Z" }, - { url = "https://files.pythonhosted.org/packages/87/a4/3c8702d25e21f420e88707701af15006e72a2a2b9f3fa419c7c80ce7451c/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6cef43f248b3635de9b82337e0ed2c7403aa1506e51587144d552702eb9d0775", size = 12839612, upload-time = "2026-09-02T11:58:10.745Z" }, - { url = "https://files.pythonhosted.org/packages/be/bf/44a7e7a1e309136a1b086332feb03c7718169af550bdbf7eab52ae0497e0/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e1fffac4b5a02c5ec74d184d668c5b882f80fa1d961e7adba6e1755877af41e1", size = 13488945, upload-time = "2026-09-02T11:58:13.15Z" }, - { url = "https://files.pythonhosted.org/packages/52/83/5321d5fb67ff16195fb0c3bd5434be4532c85967c80546092a1cf3654cc9/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:849026be5b36cf7b95d5fce63a84aa7b17248e83b4374e67715e7387ca2be50c", size = 12592235, upload-time = "2026-09-02T11:58:15.58Z" }, - { url = "https://files.pythonhosted.org/packages/12/aa/0b4e110729a86b434196d15e2e2839d992a9b8f3003f0569c77e27a9faca/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cc688349c8e8955d346cc5cc7759b68742edc587ae47ba5783a096502a7a92", size = 13259593, upload-time = "2026-09-02T11:58:17.971Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3a/e4f199cfc9f131411543aacdf6811d8b72b81ce6ac6e9f6ddffecfc31e54/curl_cffi-0.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:72376595490c4822ad1a5360adb568660ca66dff4ba2c2de2912778c15f43edb", size = 2031033, upload-time = "2026-09-02T11:58:19.949Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/9354e5552982d38abd3ce2db859f049fee6bff0eee4e25aacaaa2b29f0b4/curl_cffi-0.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b450fad876aa9f9ed3edfb6e3a48a8c28eafa66aae634eff17800a8b5006568d", size = 1782234, upload-time = "2026-09-02T11:58:21.629Z" }, ] [[package]] @@ -1028,16 +862,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/08/a2eea4f20b85897740efe44887e1ac08f30dfa4bfc3de8962bdcbb21a5a1/fastavro-1.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:469fecb25cba07f2e1bfa4c8d008477cd6b5b34a59d48715e1b1a73f6160097d", size = 3432217, upload-time = "2025-10-10T15:41:53.149Z" }, { url = "https://files.pythonhosted.org/packages/87/bb/b4c620b9eb6e9838c7f7e4b7be0762834443adf9daeb252a214e9ad3178c/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d71c8aa841ef65cfab709a22bb887955f42934bced3ddb571e98fdbdade4c609", size = 3366742, upload-time = "2025-10-10T15:41:55.237Z" }, { url = "https://files.pythonhosted.org/packages/3d/d1/e69534ccdd5368350646fea7d93be39e5f77c614cca825c990bd9ca58f67/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b81fc04e85dfccf7c028e0580c606e33aa8472370b767ef058aae2c674a90746", size = 3383743, upload-time = "2025-10-10T15:41:57.68Z" }, - { url = "https://files.pythonhosted.org/packages/58/54/b7b4a0c3fb5fcba38128542da1b26c4e6d69933c923f493548bdfd63ab6a/fastavro-1.12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9445da127751ba65975d8e4bdabf36bfcfdad70fc35b2d988e3950cce0ec0e7c", size = 1001377, upload-time = "2025-10-10T15:41:59.241Z" }, - { url = "https://files.pythonhosted.org/packages/1e/4f/0e589089c7df0d8f57d7e5293fdc34efec9a3b758a0d4d0c99a7937e2492/fastavro-1.12.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed924233272719b5d5a6a0b4d80ef3345fc7e84fc7a382b6232192a9112d38a6", size = 3320401, upload-time = "2025-10-10T15:42:01.682Z" }, - { url = "https://files.pythonhosted.org/packages/f9/19/260110d56194ae29d7e423a336fccea8bcd103196d00f0b364b732bdb84e/fastavro-1.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3616e2f0e1c9265e92954fa099db79c6e7817356d3ff34f4bcc92699ae99697c", size = 3350894, upload-time = "2025-10-10T15:42:04.073Z" }, - { url = "https://files.pythonhosted.org/packages/d0/96/58b0411e8be9694d5972bee3167d6c1fd1fdfdf7ce253c1a19a327208f4f/fastavro-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb0337b42fd3c047fcf0e9b7597bd6ad25868de719f29da81eabb6343f08d399", size = 3229644, upload-time = "2025-10-10T15:42:06.221Z" }, - { url = "https://files.pythonhosted.org/packages/5b/db/38660660eac82c30471d9101f45b3acfdcbadfe42d8f7cdb129459a45050/fastavro-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64961ab15b74b7c168717bbece5660e0f3d457837c3cc9d9145181d011199fa7", size = 3329704, upload-time = "2025-10-10T15:42:08.384Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a9/1672910f458ecb30b596c9e59e41b7c00309b602a0494341451e92e62747/fastavro-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:792356d320f6e757e89f7ac9c22f481e546c886454a6709247f43c0dd7058004", size = 452911, upload-time = "2025-10-10T15:42:09.795Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/2e15d0938ded1891b33eff252e8500605508b799c2e57188a933f0bd744c/fastavro-1.12.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120aaf82ac19d60a1016afe410935fe94728752d9c2d684e267e5b7f0e70f6d9", size = 3541999, upload-time = "2025-10-10T15:42:11.794Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1c/6dfd082a205be4510543221b734b1191299e6a1810c452b6bc76dfa6968e/fastavro-1.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6a3462934b20a74f9ece1daa49c2e4e749bd9a35fa2657b53bf62898fba80f5", size = 3433972, upload-time = "2025-10-10T15:42:14.485Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/9de694625a1a4b727b1ad0958d220cab25a9b6cf7f16a5c7faa9ea7b2261/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f81011d54dd47b12437b51dd93a70a9aa17b61307abf26542fc3c13efbc6c51", size = 3368752, upload-time = "2025-10-10T15:42:16.618Z" }, - { url = "https://files.pythonhosted.org/packages/fa/93/b44f67589e4d439913dab6720f7e3507b0fa8b8e56d06f6fc875ced26afb/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43ded16b3f4a9f1a42f5970c2aa618acb23ea59c4fcaa06680bdf470b255e5a8", size = 3386636, upload-time = "2025-10-10T15:42:18.974Z" }, ] [[package]] @@ -1127,22 +951,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] @@ -1200,38 +1008,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -1251,8 +1027,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "glom" }, { name = "httpx" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/99/165b68d01a243a6bbbe147fcdc6cb7e39b7287a5fcaa37d72e0542ba34da/geckoterminal_py-0.2.5.tar.gz", hash = "sha256:54be3953a44cefe0f3f46ffd4b1deec5eca09b295db8d42d9baf84ba0a565e15", size = 196040, upload-time = "2025-04-09T23:05:53.72Z" } wheels = [ @@ -1392,16 +1167,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, - { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, - { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, - { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, - { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, - { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, - { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] @@ -1427,14 +1192,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" }, { url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" }, { url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0f/fcd2504015eab26358d8f0f232a1aed6b8d363a011adef83fe130bff88f7/hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7", size = 3796493, upload-time = "2026-03-13T06:58:39.267Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/19c25105ff81731ca6d55a188b5de2aa99d7a2644c7aa9de1810d5d3b726/hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418", size = 3555797, upload-time = "2026-03-13T06:58:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/8933c073186849b5e06762aa89847991d913d10a95d1603eb7f2c3834086/hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146", size = 4212127, upload-time = "2026-03-13T06:58:30.539Z" }, - { url = "https://files.pythonhosted.org/packages/eb/01/f89ebba4e369b4ed699dcb60d3152753870996f41c6d22d3d7cac01310e1/hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0", size = 3987788, upload-time = "2026-03-13T06:58:29.139Z" }, - { url = "https://files.pythonhosted.org/packages/84/4d/8a53e5ffbc2cc33bbf755382ac1552c6d9af13f623ed125fe67cc3e6772f/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d", size = 4188315, upload-time = "2026-03-13T06:58:48.017Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b8/b7a1c1b5592254bd67050632ebbc1b42cc48588bf4757cb03c2ef87e704a/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570", size = 4428306, upload-time = "2026-03-13T06:58:49.502Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/40779e45b20e11c7c5821a94135e0207080d6b3d76e7b78ccb413c6f839b/hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6", size = 3665826, upload-time = "2026-03-13T06:58:59.88Z" }, - { url = "https://files.pythonhosted.org/packages/51/4c/e2688c8ad1760d7c30f7c429c79f35f825932581bc7c9ec811436d2f21a0/hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8", size = 3529113, upload-time = "2026-03-13T06:58:58.491Z" }, { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, @@ -1487,13 +1244,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -1679,31 +1429,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, - { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, - { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, - { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, - { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, - { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, - { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, - { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, - { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, @@ -1861,36 +1586,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, @@ -1998,72 +1693,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, - { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, - { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, - { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, - { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, - { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, - { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, - { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, - { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, - { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, - { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, - { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, - { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, - { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, - { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, - { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, - { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, - { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, - { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, - { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, - { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, - { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, - { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, - { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, - { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, - { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, - { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, - { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, - { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, ] [[package]] @@ -2125,20 +1754,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, ] [[package]] @@ -2272,42 +1887,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -2443,13 +2022,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, - { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] [[package]] @@ -2641,21 +2213,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, ] [[package]] @@ -2667,74 +2224,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.14'" }, - { name = "pytz", marker = "python_full_version >= '3.14'" }, - { name = "tzdata", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, -] - [[package]] name = "pandas" version = "3.0.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "python-dateutil", marker = "python_full_version < '3.14'" }, - { name = "tzdata", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } wheels = [ @@ -2761,22 +2258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, - { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, - { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, - { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, - { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, - { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, - { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, - { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, - { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, - { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, ] [[package]] @@ -2786,8 +2267,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numba" }, { name = "numpy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/6d/60b88a0334a8c6a5be114ed2c46c8f3e164127d0eccd9ff99b50773f2b20/pandas_ta-0.4.71b0.tar.gz", hash = "sha256:782ef8a874d2e0bdf80f445136617bda084f1fc5d14d3b1c525b282a152de37a", size = 1310317, upload-time = "2025-09-14T19:08:36.151Z" } @@ -2864,31 +2344,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] @@ -3001,36 +2456,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] @@ -3273,34 +2698,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, @@ -3514,7 +2911,6 @@ name = "python-telegram-bot" version = "22.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpcore", marker = "python_full_version >= '3.14'" }, { name = "httpx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } @@ -3543,16 +2939,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] @@ -3592,9 +2978,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] @@ -3632,24 +3015,6 @@ wheels = [ { 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]] @@ -3720,38 +3085,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, ] [[package]] @@ -3845,35 +3178,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -3939,26 +3243,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] @@ -3966,8 +3250,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -4151,20 +3435,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] [[package]] @@ -4234,7 +3504,7 @@ name = "types-requests" version = "2.33.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "urllib3", marker = "sys_platform != 'emscripten'" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } wheels = [ @@ -4343,18 +3613,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] @@ -4417,29 +3675,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] @@ -4484,24 +3719,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -4531,26 +3748,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] @@ -4638,42 +3835,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] @@ -4687,8 +3848,7 @@ dependencies = [ { name = "lxml" }, { name = "multitasking" }, { name = "numpy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, { name = "peewee" }, { name = "platformdirs" }, { name = "protobuf" },