diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d8a4343..fd2e0228 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,8 @@ jobs: python-version: - "3.11" - "3.12" + - "3.13" + - "3.14" name: Python ${{ matrix.python-version }} @@ -45,6 +47,9 @@ jobs: - name: Install dependencies run: uv sync --extra dev + - name: Validate YAML config files + run: uv run python misc/validate_yaml.py + - name: Lint with ruff run: uv run ruff check . diff --git a/Containerfile b/Containerfile index 462e75e8..1f03d1be 100644 --- a/Containerfile +++ b/Containerfile @@ -12,6 +12,9 @@ COPY pyproject.toml README.md ./ # Using --no-dev to exclude optional dev dependencies RUN uv sync --frozen --no-dev || uv sync --no-dev +# Copy configuration and schema files +COPY asu.yaml asu_schema.json ./ + # Copy application code COPY ./asu/ ./asu/ diff --git a/asu.yaml b/asu.yaml new file mode 100644 index 00000000..376d3d86 --- /dev/null +++ b/asu.yaml @@ -0,0 +1,271 @@ +# OpenWrt Attended Sysupgrade Server — branch & package-change definitions +# +# This file is auto-reloaded when modified — no server restart required. +# +# Top-level sections: +# branches — release branch definitions +# revision_changes — revision-based rename/add/delete (served to clients) +# version_changes — version/target/profile-specific build-time mutations +# language_packs — prefix-based language pack renames + +# Branch definitions. +# +# Each key is the branch name (e.g. "SNAPSHOT", "24.10"). +# Non-SNAPSHOT branches default to: +# path: "releases/{version}" +# enabled: true +# snapshot: false +# +# IMPORTANT: Quote branch names that look like numbers (e.g. "24.10") +# to prevent YAML from interpreting them as floats. +branches: + SNAPSHOT: + path: snapshots + enabled: true + snapshot: true + + "25.12": + branch_off_rev: 32295 + + "24.10": + branch_off_rev: 27990 + + "23.05": + branch_off_rev: 23069 + + "22.03": + branch_off_rev: 19160 + + "21.02": + branch_off_rev: 15812 + +# Revision-based changes: used by branch configs and served to clients. +# +# Adding a new entry requires determining the revision at which the package +# appears, is removed, or has been renamed/replaced. To find the revision: +# +# 1) Look up the date of the commit for the package change. +# 2) Use 'scripts/getver.sh yyyy-mm-dd' in buildroot to get the revision. +# See https://github.com/openwrt/openwrt/commit/e56845fae3c0 +# +# Interpretation: +# rename/replace = both 'source' and 'target' specified +# added = only 'source' specified +# deleted = only 'target' specified +# mandatory = package must be added or deleted (default package list changed) +revision_changes: + - source: firewall + target: firewall4 + revision: 18611 + - source: kmod-nft-nat6 + revision: 20282 + mandatory: true + - source: libustream-wolfssl + target: libustream-mbedtls + revision: 21994 + - source: px5g-wolfssl + target: px5g-mbedtls + revision: 21994 + - source: wpad-basic-wolfssl + target: wpad-basic-mbedtls + revision: 21994 + - source: luci-app-diag-core + revision: 25984 + mandatory: true + - source: auc + target: owut + revision: 26792 + - source: luci-app-opkg + target: luci-app-package-manager + revision: 27897 + - source: opkg + target: apk-mbedtls + revision: 28056 + +# Version/target/profile-specific build-time changes. +# +# Each entry matches builds whose version starts with the given version +# prefix. Rules within an entry are applied in order. +# +# Rule fields: +# target — match a specific target (e.g. "mediatek/mt7622") +# targets — match any of these targets +# profiles — list of {names: [...], add: [...]} profile-scoped additions +# add — packages to add (if missing) when target/targets match +# remove — packages to remove (if present) +# replace — {old: new} package replacements +version_changes: + - version: "23.05" + rules: + - target: "mediatek/mt7622" + add: ["kmod-mt7622-firmware"] + - target: "ath79/generic" + profiles: + - names: + - buffalo_wzr-hp-g300nh-s + - dlink_dir-825-b1 + - netgear_wndr3700 + - netgear_wndr3700-v2 + - netgear_wndr3800 + - netgear_wndr3800ch + - netgear_wndrmac-v1 + - netgear_wndrmac-v2 + - trendnet_tew-673gru + add: ["kmod-switch-rtl8366s"] + - names: + - buffalo_wzr-hp-g300nh-rb + add: ["kmod-switch-rtl8366rb"] + + - version: "24.10" + rules: + - replace: + auc: owut + - profiles: + - names: + - tplink_archer-c6-v2 + add: ["ipq-wifi-tplink_archer-c6-v2"] + - targets: + - "mediatek/filogic" + - "mediatek/mt7622" + - "mediatek/mt7623" + add: ["fitblk"] + + - version: "25.12" + rules: + # https://github.com/openwrt/openwrt/commit/8a7239009c5f4b28b696042b70ed1f8f89902915 + - target: "kirkwood/generic" + profiles: + - names: + - checkpoint_l-50 + - endian_4i-edge-200 + - linksys_e4200-v2 + - linksys_ea3500 + - linksys_ea4500 + add: ["kmod-dsa-mv88e6xxx"] + # https://github.com/openwrt/openwrt/commit/eaa82118eadfd495f8512d55c01c1935b8b42c51 + - target: "mvebu/cortexa9" + profiles: + - names: + - cznic_turris-omnia + - fortinet_fg-30e + - fortinet_fwf-30e + - fortinet_fg-50e + - fortinet_fg-51e + - fortinet_fg-52e + - fortinet_fwf-50e-2r + - fortinet_fwf-51e + - iij_sa-w2 + - linksys_wrt1200ac + - linksys_wrt1900acs + - linksys_wrt1900ac-v1 + - linksys_wrt1900ac-v2 + - linksys_wrt3200acm + - linksys_wrt32x + - marvell_a370-rd + add: ["kmod-dsa-mv88e6xxx"] + - target: "mvebu/cortexa53" + profiles: + - names: + - glinet_gl-mv1000 + - globalscale_espressobin + - globalscale_espressobin-emmc + - globalscale_espressobin-ultra + - globalscale_espressobin-v7 + - globalscale_espressobin-v7-emmc + - methode_udpu + add: ["kmod-dsa-mv88e6xxx"] + - target: "mvebu/cortexa72" + profiles: + - names: + - checkpoint_v-80 + - checkpoint_v-81 + - globalscale_mochabin + - mikrotik_rb5009 + - solidrun_clearfog-pro + add: ["kmod-dsa-mv88e6xxx"] + # https://github.com/openwrt/openwrt/commit/a18d95f35bd54ade908e8ec3158435859402552d + - target: "lantiq/xrx200" + profiles: + - names: + - arcadyan_arv7519rw22 + - arcadyan_vgv7510kw22-brn + - arcadyan_vgv7510kw22-nor + - avm_fritz7412 + - avm_fritz7430 + - buffalo_wbmr-300hpd + add: + - xrx200-rev1.1-phy22f-firmware + - xrx200-rev1.2-phy22f-firmware + - names: + - tplink_vr200 + - tplink_vr200v + - arcadyan_vgv7519-brn + - arcadyan_vgv7519-nor + - arcadyan_vrv9510kwac23 + - avm_fritz3370-rev2-hynix + - avm_fritz3370-rev2-micron + - avm_fritz3390 + - avm_fritz3490 + - avm_fritz3490-micron + - avm_fritz5490 + - avm_fritz5490-micron + - avm_fritz7360sl + - avm_fritz7360-v2 + - avm_fritz7362sl + - avm_fritz7490 + - avm_fritz7490-micron + - bt_homehub-v5a + - lantiq_easy80920-nand + - lantiq_easy80920-nor + - zyxel_p-2812hnu-f1 + - zyxel_p-2812hnu-f3 + add: + - xrx200-rev1.1-phy11g-firmware + - xrx200-rev1.2-phy11g-firmware + - target: "lantiq/xrx200_legacy" + profiles: + - names: + - alphanetworks_asl56026 + - netgear_dm200 + add: + - xrx200-rev1.1-phy22f-firmware + - xrx200-rev1.2-phy22f-firmware + - names: + - tplink_tdw8970 + - tplink_tdw8980 + - arcadyan_vg3503j + add: + - xrx200-rev1.1-phy11g-firmware + - xrx200-rev1.2-phy11g-firmware + # https://github.com/openwrt/openwrt/commit/3b7a92754e81432024b232c7cd7fe32593891ee0 + - target: "bcm53xx/generic" + profiles: + - names: + - meraki_mr32 + add: ["kmod-hci-uart"] + - target: "ipq40xx/generic" + profiles: + - names: + - linksys_whw03 + - linksys_whw03v2 + add: ["kmod-hci-uart"] + - target: "qualcommax/ipq807x" + profiles: + - names: + - linksys_mx4200v1 + - linksys_mx8500 + - zyxel_nbg7815 + add: ["kmod-hci-uart"] + + - version: SNAPSHOT + comment: "Change 'SNAPSHOT' to 26.x when needed." + rules: + # https://github.com/openwrt/openwrt/commit/5b61a50244ebc82096f5949de294ad69851e1fd6 + - remove: ["kmod-nf-conntrack6"] + +# Language pack prefix replacements. +# Applied to all versions >= min_version (including snapshots). +language_packs: + - min_version: "24.10" + replacements: + "luci-i18n-opkg-": "luci-i18n-package-manager-" diff --git a/asu/branches.py b/asu/branches.py new file mode 100644 index 00000000..c1ef1547 --- /dev/null +++ b/asu/branches.py @@ -0,0 +1,82 @@ +import json +import logging +from pathlib import Path + +import jsonschema +import yaml + +log = logging.getLogger("rq.worker") + +_schema_path = Path(__file__).resolve().parent.parent / "asu_schema.json" +_schema = json.loads(_schema_path.read_text()) + +_DEFAULTS = { + "path": "releases/{version}", + "enabled": True, + "snapshot": False, +} + +# --- Mtime-based auto-reload cache --- + +_cached_branches: dict[str, dict] | None = None +_cached_mtime: float = 0 +_cached_path: Path | None = None + +# Optional overrides injected by tests (branch_name -> dict). +_overrides: dict[str, dict] = {} + + +def _load_branches(path: Path | None = None) -> dict[str, dict]: + global _cached_branches, _cached_mtime, _cached_path + + if path is None: + from asu.config import settings + + path = settings.openwrt_config_file + + path = Path(path) + mtime = path.stat().st_mtime + + if path == _cached_path and mtime == _cached_mtime and _cached_branches is not None: + return _cached_branches + + with open(path) as f: + raw = yaml.safe_load(f) or {} + + jsonschema.validate(raw, _schema) + + branches: dict[str, dict] = {} + for name, data in (raw.get("branches") or {}).items(): + # YAML parses numeric-looking keys (e.g. 24.10) as floats; + # coerce all branch names to strings. + name = str(name) + branch = {**_DEFAULTS, **(data or {})} + branches[name] = branch + + _cached_branches = branches + _cached_mtime = mtime + _cached_path = path + log.debug(f"Loaded branch definitions from {path}") + + return _cached_branches + + +def get_branches() -> dict[str, dict]: + """Return all branch definitions, auto-reloading from YAML on change. + + Any overrides added via ``set_branch_override`` are merged on top. + """ + branches = dict(_load_branches()) + if _overrides: + branches.update(_overrides) + return branches + + +def set_branch_override(name: str, data: dict) -> None: + """Add a branch override (used by tests to inject extra branches).""" + _overrides[name] = data + + +def clear_branch_overrides() -> None: + """Remove all branch overrides.""" + _overrides.clear() diff --git a/asu/config.py b/asu/config.py index 92c57041..06a69ba1 100644 --- a/asu/config.py +++ b/asu/config.py @@ -1,64 +1,7 @@ from pathlib import Path -from typing import Union from pydantic_settings import BaseSettings, SettingsConfigDict -# Adding a new entry to `package_changes_list` requires determining -# the revision at which the package appears, is removed or has been -# renamed/replaced. To find the revision number: -# -# 1) Look up the date of the commit for the package change, -# - either in the package repo itself (say, when auc was deleted); or -# - in the openwrt repo where the package swap occurred (firewall4 -# in include/target.mk is a good example of this one). -# -# 2) Use 'scripts/getver.sh yyyy-mm-dd' in buildroot to get the revision. -# See https://github.com/openwrt/openwrt/commit/e56845fae3c0 -# -# Clients should interpret the table as follows: -# -# rename/replace = if 'source' and 'target' both specified -# added = if only 'source' is specified -# deleted = if only 'target' is specified -# -# If 'mandatory' is true, this package must be added or deleted (probably -# because the default package list has changed). - -package_changes_list = [ - {"source": "firewall", "target": "firewall4", "revision": 18611}, - {"source": "kmod-nft-nat6", "revision": 20282, "mandatory": True}, - {"source": "libustream-wolfssl", "target": "libustream-mbedtls", "revision": 21994}, - {"source": "px5g-wolfssl", "target": "px5g-mbedtls", "revision": 21994}, - {"source": "wpad-basic-wolfssl", "target": "wpad-basic-mbedtls", "revision": 21994}, - {"source": "luci-app-diag-core", "revision": 25984, "mandatory": True}, - {"source": "auc", "target": "owut", "revision": 26792}, - { - "source": "luci-app-opkg", - "target": "luci-app-package-manager", - "revision": 27897, - }, - {"source": "opkg", "target": "apk-mbedtls", "revision": 28056}, -] - - -def package_changes(before=None): - changes = [] - for change in package_changes_list: - if before is None or change["revision"] <= before: - changes.append(change) - return changes - - -def release(branch_off_rev, enabled=True): - return { - "path": "releases/{version}", - "enabled": enabled, - "snapshot": False, - "path_packages": "DEPRECATED", - "branch_off_rev": branch_off_rev, - "package_changes": package_changes(branch_off_rev), - } - class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") @@ -68,27 +11,13 @@ class Settings(BaseSettings): upstream_url: str = "https://downloads.openwrt.org" allow_defaults: bool = False async_queue: bool = True - branches_file: Union[str, Path, None] = None + openwrt_config_file: Path = Path("asu.yaml") max_custom_rootfs_size_mb: int = 1024 max_defaults_length: int = 20480 repository_allow_list: list = [] base_container: str = "ghcr.io/openwrt/imagebuilder" container_socket_path: str = "" container_identity: str = "" - branches: dict = { - "SNAPSHOT": { - "path": "snapshots", - "enabled": True, - "snapshot": True, - "path_packages": "DEPRECATED", - "package_changes": package_changes(), - }, - "25.12": release(32295), - "24.10": release(27990), - "23.05": release(23069), - "22.03": release(19160), - "21.02": release(15812, enabled=True), # Enabled for now... - } server_stats: str = "" log_level: str = "INFO" squid_cache: bool = False diff --git a/asu/main.py b/asu/main.py index 8602bac4..17628adf 100644 --- a/asu/main.py +++ b/asu/main.py @@ -10,6 +10,7 @@ from fastapi.templating import Jinja2Templates from asu import __version__ +from asu.branches import get_branches from asu.config import settings from asu.routers import api, stats from asu.util import ( @@ -67,7 +68,7 @@ def index(request: Request): name="overview.html", context=dict( versions=app.versions, - branches=reversed(settings.branches), + branches=reversed(get_branches()), defaults=settings.allow_defaults, version=__version__, server_stats=settings.server_stats, @@ -132,12 +133,17 @@ def json_v1_latest(): def generate_branches(): + from asu.package_changes import get_revision_changes + reload_versions(app) # Do a reload in case .versions.json has updated. - branches = dict(**settings.branches) + branches = {k: dict(v) for k, v in get_branches().items()} for branch in branches: branches[branch]["versions"] = [] branches[branch]["name"] = branch + branches[branch]["package_changes"] = get_revision_changes( + branches[branch].get("branch_off_rev") + ) for version in app.versions: branch_name = get_branch(version)["name"] diff --git a/asu/package_changes.py b/asu/package_changes.py index 25fb3ad9..8c974e21 100644 --- a/asu/package_changes.py +++ b/asu/package_changes.py @@ -1,217 +1,147 @@ +import json import logging +from pathlib import Path + +import jsonschema +import yaml from asu.build_request import BuildRequest log = logging.getLogger("rq.worker") +_schema_path = Path(__file__).resolve().parent.parent / "asu_schema.json" +_schema = json.loads(_schema_path.read_text()) + +# --- Mtime-based auto-reload cache --- + +_cached_data: dict | None = None +_cached_mtime: float = 0 +_cached_path: Path | None = None + + +def _load_package_changes(path: Path | None = None) -> dict: + global _cached_data, _cached_mtime, _cached_path + + if path is None: + from asu.config import settings + + path = settings.openwrt_config_file + + path = Path(path) + mtime = path.stat().st_mtime + + if path == _cached_path and mtime == _cached_mtime and _cached_data is not None: + return _cached_data -# Language pack replacements are done generically on a per-version basis. -# Note that the version comparison below applies to all versions the same -# or newer, so for example "24.10" applies to snapshots, too. -language_packs = { - "24.10": { - "luci-i18n-opkg-": "luci-i18n-package-manager-", - }, -} + with open(path) as f: + raw = yaml.safe_load(f) + jsonschema.validate(raw, _schema) -def apply_package_changes(build_request: BuildRequest): + _cached_data = raw + _cached_mtime = mtime + _cached_path = path + log.debug(f"Loaded package changes from {path}") + + return _cached_data + + +def get_revision_changes(before: int | None = None) -> list[dict]: + """Return revision-based package changes, optionally filtered by revision. + + This replaces the old ``package_changes()`` function from config.py. + Results are serialised as plain dicts for JSON API responses. """ - Apply package changes to the request + data = _load_package_changes() + changes = [] + for rc in data["revision_changes"]: + if before is None or rc["revision"] <= before: + entry: dict = {"revision": rc["revision"]} + if "source" in rc: + entry["source"] = rc["source"] + if "target" in rc: + entry["target"] = rc["target"] + if rc.get("mandatory"): + entry["mandatory"] = rc["mandatory"] + changes.append(entry) + return changes + + +# --- Build-time package mutations --- + + +def apply_package_changes(build_request: BuildRequest) -> None: + """Apply package changes to the request. - Args: - req (dict): The image request - log (logging.Logger): The logger to use + Reads rules from the YAML file (auto-reloaded on change). """ + data = _load_package_changes() - def _add_if_missing(package): + def _add_if_missing(package: str) -> None: if package not in build_request.packages: build_request.packages.append(package) log.debug(f"Added {package} to packages") - def _remove_if_present(package): + def _remove_if_present(package: str) -> bool: if package in build_request.packages: build_request.packages.remove(package) log.debug(f"Removed {package} from packages") return True return False - # 23.05 specific changes - if build_request.version.startswith("23.05"): - # mediatek/mt7622 specific changes - if build_request.target == "mediatek/mt7622": - _add_if_missing("kmod-mt7622-firmware") - - # ath79/generic specific changes - elif build_request.target == "ath79/generic": - if build_request.profile in { - "buffalo_wzr-hp-g300nh-s", - "dlink_dir-825-b1", - "netgear_wndr3700", - "netgear_wndr3700-v2", - "netgear_wndr3800", - "netgear_wndr3800ch", - "netgear_wndrmac-v1", - "netgear_wndrmac-v2", - "trendnet_tew-673gru", - }: - _add_if_missing("kmod-switch-rtl8366s") - - elif build_request.profile == "buffalo_wzr-hp-g300nh-rb": - _add_if_missing("kmod-switch-rtl8366rb") - - if build_request.version.startswith("24.10"): - # `auc` no longer exists here - if _remove_if_present("auc"): - _add_if_missing("owut") - - if build_request.profile in {"tplink_archer-c6-v2"}: - _add_if_missing("ipq-wifi-tplink_archer-c6-v2") - - if build_request.target in { - "mediatek/filogic", - "mediatek/mt7622", - "mediatek/mt7623", - }: - _add_if_missing("fitblk") - - # 25.12 specific changes - if build_request.version.startswith("25.12"): - # Changes for https://github.com/openwrt/openwrt/commit/8a7239009c5f4b28b696042b70ed1f8f89902915 - if build_request.target == "kirkwood/generic": - if build_request.profile in { - "checkpoint_l-50", - "endian_4i-edge-200", - "linksys_e4200-v2", - "linksys_ea3500", - "linksys_ea4500", - }: - _add_if_missing("kmod-dsa-mv88e6xxx") - # Changes for https://github.com/openwrt/openwrt/commit/eaa82118eadfd495f8512d55c01c1935b8b42c51 - elif build_request.target == "mvebu/cortexa9": - if build_request.profile in { - "cznic_turris-omnia", - "fortinet_fg-30e", - "fortinet_fwf-30e", - "fortinet_fg-50e", - "fortinet_fg-51e", - "fortinet_fg-52e", - "fortinet_fwf-50e-2r", - "fortinet_fwf-51e", - "iij_sa-w2", - "linksys_wrt1200ac", - "linksys_wrt1900acs", - "linksys_wrt1900ac-v1", - "linksys_wrt1900ac-v2", - "linksys_wrt3200acm", - "linksys_wrt32x", - "marvell_a370-rd", - }: - _add_if_missing("kmod-dsa-mv88e6xxx") - # Changes for https://github.com/openwrt/openwrt/commit/eaa82118eadfd495f8512d55c01c1935b8b42c51 - elif build_request.target == "mvebu/cortexa53": - if build_request.profile in { - "glinet_gl-mv1000", - "globalscale_espressobin", - "globalscale_espressobin-emmc", - "globalscale_espressobin-ultra", - "globalscale_espressobin-v7", - "globalscale_espressobin-v7-emmc", - "methode_udpu", - }: - _add_if_missing("kmod-dsa-mv88e6xxx") - # Changes for https://github.com/openwrt/openwrt/commit/eaa82118eadfd495f8512d55c01c1935b8b42c51 - elif build_request.target == "mvebu/cortexa72": - if build_request.profile in { - "checkpoint_v-80", - "checkpoint_v-81", - "globalscale_mochabin", - "mikrotik_rb5009", - "solidrun_clearfog-pro", - }: - _add_if_missing("kmod-dsa-mv88e6xxx") - # Changes for https://github.com/openwrt/openwrt/commit/a18d95f35bd54ade908e8ec3158435859402552d - elif build_request.target == "lantiq/xrx200": - if build_request.profile in { - "arcadyan_arv7519rw22", - "arcadyan_vgv7510kw22-brn", - "arcadyan_vgv7510kw22-nor", - "avm_fritz7412", - "avm_fritz7430", - "buffalo_wbmr-300hpd", - }: - _add_if_missing("xrx200-rev1.1-phy22f-firmware") - _add_if_missing("xrx200-rev1.2-phy22f-firmware") - elif build_request.profile in { - "tplink_vr200", - "tplink_vr200v", - "arcadyan_vgv7519-brn", - "arcadyan_vgv7519-nor", - "arcadyan_vrv9510kwac23", - "avm_fritz3370-rev2-hynix", - "avm_fritz3370-rev2-micron", - "avm_fritz3390", - "avm_fritz3490", - "avm_fritz3490-micron", - "avm_fritz5490", - "avm_fritz5490-micron", - "avm_fritz7360sl", - "avm_fritz7360-v2", - "avm_fritz7362sl", - "avm_fritz7490", - "avm_fritz7490-micron", - "bt_homehub-v5a", - "lantiq_easy80920-nand", - "lantiq_easy80920-nor", - "zyxel_p-2812hnu-f1", - "zyxel_p-2812hnu-f3", - }: - _add_if_missing("xrx200-rev1.1-phy11g-firmware") - _add_if_missing("xrx200-rev1.2-phy11g-firmware") - # Changes for https://github.com/openwrt/openwrt/commit/a18d95f35bd54ade908e8ec3158435859402552d - elif build_request.target == "lantiq/xrx200_legacy": - if build_request.profile in { - "alphanetworks_asl56026", - "netgear_dm200", - }: - _add_if_missing("xrx200-rev1.1-phy22f-firmware") - _add_if_missing("xrx200-rev1.2-phy22f-firmware") - elif build_request.profile in { - "tplink_tdw8970", - "tplink_tdw8980", - "arcadyan_vg3503j", - }: - _add_if_missing("xrx200-rev1.1-phy11g-firmware") - _add_if_missing("xrx200-rev1.2-phy11g-firmware") - # Changes for https://github.com/openwrt/openwrt/commit/3b7a92754e81432024b232c7cd7fe32593891ee0 - elif build_request.target == "bcm53xx/generic": - if build_request.profile in { - "meraki_mr32", - }: - _add_if_missing("kmod-hci-uart") - elif build_request.target == "ipq40xx/generic": - if build_request.profile in { - "linksys_whw03", - "linksys_whw03v2", - }: - _add_if_missing("kmod-hci-uart") - elif build_request.target == "qualcommax/ipq807x": - if build_request.profile in { - "linksys_mx4200v1", - "linksys_mx8500", - "zyxel_nbg7815", - }: - _add_if_missing("kmod-hci-uart") - - if build_request.version == "SNAPSHOT": # Change "SNAPSHOT" to 26.x when needed. - # https://github.com/openwrt/openwrt/commit/5b61a50244ebc82096f5949de294ad69851e1fd6 - _remove_if_present("kmod-nf-conntrack6") - - # TODO: if we ever fully implement 'packages_versions', this needs rework - for version, packages in language_packs.items(): - if build_request.version >= version: # Includes snapshots + # Apply version/target/profile rules + for vc in data["version_changes"]: + version = vc["version"] + if version == "SNAPSHOT": + version_match = build_request.version == "SNAPSHOT" + else: + version_match = build_request.version.startswith(version) + + if not version_match: + continue + + for rule in vc["rules"]: + # Replace rules (not scoped to target) + if "replace" in rule: + for old_pkg, new_pkg in rule["replace"].items(): + if _remove_if_present(old_pkg): + _add_if_missing(new_pkg) + + # Remove rules (not scoped to target) + if "remove" in rule and "target" not in rule and "targets" not in rule: + for pkg in rule["remove"]: + _remove_if_present(pkg) + + # Target-scoped rules + target_match = False + if "target" in rule and build_request.target == rule["target"]: + target_match = True + elif "targets" in rule and build_request.target in rule["targets"]: + target_match = True + + if target_match and "add" in rule: + for pkg in rule["add"]: + _add_if_missing(pkg) + + if target_match and "remove" in rule: + for pkg in rule["remove"]: + _remove_if_present(pkg) + + # Profile-scoped rules (may or may not require target match) + if "profiles" in rule: + # If a target is specified, only apply profiles when target matches + if ("target" in rule or "targets" in rule) and not target_match: + continue + for profile_rule in rule["profiles"]: + if build_request.profile in profile_rule["names"]: + for pkg in profile_rule["add"]: + _add_if_missing(pkg) + + # Apply language pack replacements + for lp in data["language_packs"]: + if build_request.version >= lp["min_version"]: for i, package in enumerate(build_request.packages): - for old, new in packages.items(): - if package.startswith(old): - lang = package.replace(old, "") - build_request.packages[i] = f"{new}{lang}" + for old_prefix, new_prefix in lp["replacements"].items(): + if package.startswith(old_prefix): + lang = package.removeprefix(old_prefix) + build_request.packages[i] = f"{new_prefix}{lang}" diff --git a/asu/routers/api.py b/asu/routers/api.py index b26c85de..c1139759 100644 --- a/asu/routers/api.py +++ b/asu/routers/api.py @@ -93,7 +93,9 @@ def validate_request( branch = get_branch(build_request.version)["name"] - if branch not in settings.branches: + from asu.branches import get_branches + + if branch not in get_branches(): return validation_failure(f"Unsupported branch: {build_request.version}") if build_request.version not in app.versions: diff --git a/asu/util.py b/asu/util.py index 38f24867..9ad46d7a 100644 --- a/asu/util.py +++ b/asu/util.py @@ -92,7 +92,12 @@ def get_queue() -> Queue: def get_branch(version_or_branch: str) -> dict[str, str]: - if version_or_branch not in settings.branches: + from asu.branches import get_branches + from asu.package_changes import get_revision_changes + + branches = get_branches() + + if version_or_branch not in branches: if version_or_branch.endswith("-SNAPSHOT"): # e.g. 21.02-snapshot branch_name = version_or_branch.rsplit("-", maxsplit=1)[0] @@ -102,7 +107,9 @@ def get_branch(version_or_branch: str) -> dict[str, str]: else: branch_name = version_or_branch - return {**settings.branches.get(branch_name, {}), "name": branch_name} + branch = {**branches.get(branch_name, {}), "name": branch_name} + branch["package_changes"] = get_revision_changes(branch.get("branch_off_rev")) + return branch def get_str_hash(string: str) -> str: @@ -557,7 +564,9 @@ def reload_versions(app: FastAPI) -> bool: """ def in_supported_branch(version: str) -> bool: - for branch_name, branch in settings.branches.items(): + from asu.branches import get_branches + + for branch_name, branch in get_branches().items(): if branch["enabled"] and version.startswith(branch_name): return True return False @@ -587,6 +596,8 @@ def add_versions(version_list: list, *versions: str) -> None: versions_upstream["oldstable_version"], ) + from asu.branches import get_branches + app.versions = [] add_versions( app.versions, @@ -595,7 +606,7 @@ def add_versions(version_list: list, *versions: str) -> None: "SNAPSHOT", *[ f"{branch_name}-SNAPSHOT" - for branch_name in settings.branches + for branch_name in get_branches() if branch_name != "SNAPSHOT" ], ) diff --git a/asu_schema.json b/asu_schema.json new file mode 100644 index 00000000..e2c6648e --- /dev/null +++ b/asu_schema.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ASU OpenWrt Configuration", + "description": "Schema for openwrt.yaml — defines branches, package changes, and language pack rules for the ASU server.", + "type": "object", + "required": ["branches", "revision_changes", "version_changes", "language_packs"], + "additionalProperties": false, + "properties": { + "branches": { + "description": "Release branch definitions. Keys are branch names (e.g. 'SNAPSHOT', '24.10').", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "URL path template for this branch. Use {version} as placeholder. Defaults to 'releases/{version}' for non-snapshot branches." + }, + "enabled": { + "type": "boolean", + "description": "Whether this branch is active. Defaults to true." + }, + "snapshot": { + "type": "boolean", + "description": "Whether this is a snapshot (rolling) branch. Defaults to false." + }, + "branch_off_rev": { + "type": "integer", + "minimum": 1, + "description": "OpenWrt revision at which this branch was forked. Used to determine applicable package changes." + } + } + } + }, + "revision_changes": { + "description": "Revision-based package changes served to clients for branch-level migration guidance.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["revision"], + "properties": { + "source": { + "type": "string", + "description": "Original package name (for renames/deletes)." + }, + "target": { + "type": "string", + "description": "Replacement package name (for renames/adds)." + }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "OpenWrt revision number at which this change takes effect." + }, + "mandatory": { + "type": "boolean", + "description": "If true, the package must be added or deleted (default package list changed)." + } + }, + "anyOf": [ + { "required": ["source"] }, + { "required": ["target"] } + ] + } + }, + "version_changes": { + "description": "Version/target/profile-specific build-time package mutations.", + "type": "array", + "items": { + "type": "object", + "required": ["version", "rules"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "description": "Version prefix to match (e.g. '23.05') or exact value like 'SNAPSHOT'." + }, + "comment": { + "type": "string", + "description": "Optional comment for this version change group." + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "target": { + "type": "string", + "description": "Single target to match (e.g. 'mediatek/mt7622')." + }, + "targets": { + "type": "array", + "items": { "type": "string" }, + "description": "Multiple targets to match (any of)." + }, + "profiles": { + "type": "array", + "items": { + "type": "object", + "required": ["names", "add"], + "additionalProperties": false, + "properties": { + "names": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Profile names to match." + }, + "add": { + "type": "array", + "items": { "type": "string" }, + "description": "Packages to add if missing." + } + } + }, + "description": "Profile-scoped package additions." + }, + "add": { + "type": "array", + "items": { "type": "string" }, + "description": "Packages to add if missing (when target/targets match)." + }, + "remove": { + "type": "array", + "items": { "type": "string" }, + "description": "Packages to remove if present." + }, + "replace": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Package replacements as {old_name: new_name}." + } + } + } + } + } + } + }, + "language_packs": { + "description": "Prefix-based language pack renames applied to all versions >= min_version.", + "type": "array", + "items": { + "type": "object", + "required": ["min_version", "replacements"], + "additionalProperties": false, + "properties": { + "min_version": { + "type": "string", + "description": "Minimum version (inclusive). Applies to all versions >= this, including snapshots." + }, + "replacements": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Prefix replacements as {old_prefix: new_prefix}." + } + } + } + } + } +} diff --git a/misc/validate_yaml.py b/misc/validate_yaml.py new file mode 100644 index 00000000..d4a3725d --- /dev/null +++ b/misc/validate_yaml.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Validate YAML config files against their JSON schemas.""" + +import json +import sys + +import jsonschema +import yaml + +CHECKS = [ + ("asu.yaml", "asu_schema.json"), +] + +errors = 0 +for yaml_file, schema_file in CHECKS: + with open(schema_file) as f: + schema = json.load(f) + with open(yaml_file) as f: + data = yaml.safe_load(f) + try: + jsonschema.validate(data, schema) + print(f"{yaml_file}: OK") + except jsonschema.ValidationError as e: + print(f"{yaml_file}: FAILED - {e.message}") + errors += 1 + +sys.exit(1 if errors else 0) diff --git a/pyproject.toml b/pyproject.toml index 8bbb562a..1e1d419f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "fastapi-cache2>=0.2.2", "httpx>=0.28.1", "podman-compose>=1.5.0", + "pyyaml>=6.0", + "jsonschema>=4.0", ] [project.optional-dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index df4ff57d..73584f19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from rq import Queue from fastapi.testclient import TestClient +from asu.branches import set_branch_override, clear_branch_overrides from asu.config import settings @@ -102,11 +103,14 @@ def mocked_redis_queue(): settings.upstream_url = "http://localhost:8123" settings.server_stats = "stats" for branch in "1.2", "19.07", "21.02": - if branch not in settings.branches: - settings.branches[branch] = { + set_branch_override( + branch, + { "path": "releases/{version}", "enabled": True, - } + "snapshot": False, + }, + ) monkeypatch.setattr("asu.util.get_queue", mocked_redis_queue) monkeypatch.setattr("asu.routers.api.get_queue", mocked_redis_queue) @@ -116,6 +120,8 @@ def mocked_redis_queue(): yield real_app + clear_branch_overrides() + @pytest.fixture def client(app, upstream): diff --git a/tests/test_branches.py b/tests/test_branches.py new file mode 100644 index 00000000..fa8fdfb2 --- /dev/null +++ b/tests/test_branches.py @@ -0,0 +1,83 @@ +import os +import tempfile +import time + +import yaml + +from asu.branches import _load_branches, get_branches + + +def _make_openwrt_yaml(branches): + """Wrap a branches dict in a full openwrt.yaml structure.""" + return { + "branches": branches, + "revision_changes": [], + "version_changes": [], + "language_packs": [], + } + + +def test_load_branches_from_yaml(): + branches = get_branches() + assert "SNAPSHOT" in branches + assert branches["SNAPSHOT"]["snapshot"] is True + assert branches["SNAPSHOT"]["path"] == "snapshots" + + assert "24.10" in branches + assert branches["24.10"]["branch_off_rev"] == 27990 + assert branches["24.10"]["path"] == "releases/{version}" + assert branches["24.10"]["enabled"] is True + + +def test_load_branches_defaults(): + """Non-SNAPSHOT branches get sensible defaults.""" + branches = get_branches() + for name, branch in branches.items(): + if name == "SNAPSHOT": + continue + assert branch["path"] == "releases/{version}" + assert branch["enabled"] is True + assert branch["snapshot"] is False + + +def test_branches_auto_reload(): + """Verify that modifying the YAML file causes a reload.""" + raw = _make_openwrt_yaml({"test-branch": {"branch_off_rev": 99999}}) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(raw, f) + tmp_path = f.name + + try: + data = _load_branches(tmp_path) + assert "test-branch" in data + assert data["test-branch"]["branch_off_rev"] == 99999 + + # Ensure mtime changes + time.sleep(0.05) + + raw["branches"]["new-branch"] = {"branch_off_rev": 11111} + with open(tmp_path, "w") as f: + yaml.dump(raw, f) + + data = _load_branches(tmp_path) + assert "new-branch" in data + assert data["new-branch"]["branch_off_rev"] == 11111 + finally: + os.unlink(tmp_path) + + +def test_branches_numeric_keys_coerced_to_str(): + """YAML numeric keys (e.g. unquoted 21.02) are coerced to strings.""" + # Simulate unquoted numeric key — yaml.dump will produce a float key + raw = _make_openwrt_yaml({21.02: {"branch_off_rev": 15812}}) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(raw, f) + tmp_path = f.name + + try: + data = _load_branches(tmp_path) + assert "21.02" in data + finally: + os.unlink(tmp_path) diff --git a/tests/test_package_changes.py b/tests/test_package_changes.py index bd97641e..fb618ea8 100644 --- a/tests/test_package_changes.py +++ b/tests/test_package_changes.py @@ -1,5 +1,17 @@ +import os +import tempfile +import time + +import jsonschema +import pytest +import yaml + from asu.build_request import BuildRequest -from asu.package_changes import apply_package_changes +from asu.package_changes import ( + _load_package_changes, + apply_package_changes, + get_revision_changes, +) def test_apply_package_changes_adds_kmod_switch_rtl8366s(): @@ -145,3 +157,86 @@ def test_apply_package_changes_lang_packs(): assert build_request.packages[2] == "luci-i18n-package-manager-zh-cn" assert build_request.packages[3] == "kmod-mt7622-firmware" assert build_request.packages[4] == "fitblk" + + +def test_get_revision_changes_all(): + changes = get_revision_changes() + assert len(changes) > 0 + # All entries must have a revision + for c in changes: + assert "revision" in c + assert "source" in c or "target" in c + + +def test_get_revision_changes_filtered(): + changes = get_revision_changes(before=20000) + for c in changes: + assert c["revision"] <= 20000 + # Should include firewall (18611) but not libustream-wolfssl (21994) + sources = [c.get("source") for c in changes] + assert "firewall" in sources + assert "libustream-wolfssl" not in sources + + +def test_yaml_validation_rejects_bad_data(): + bad_yaml = { + "branches": {}, + "revision_changes": [{"not_a_field": "bad"}], + "version_changes": [], + "language_packs": [], + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(bad_yaml, f) + tmp_path = f.name + try: + with pytest.raises(jsonschema.ValidationError): + _load_package_changes(tmp_path) + finally: + os.unlink(tmp_path) + + +def test_yaml_validation_requires_all_sections(): + bad_yaml = {"revision_changes": []} + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(bad_yaml, f) + tmp_path = f.name + try: + with pytest.raises(jsonschema.ValidationError): + _load_package_changes(tmp_path) + finally: + os.unlink(tmp_path) + + +def test_yaml_auto_reload(): + """Verify that modifying the YAML file causes a reload.""" + minimal_yaml = { + "branches": {}, + "revision_changes": [{"source": "old-pkg", "target": "new-pkg", "revision": 1}], + "version_changes": [], + "language_packs": [], + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(minimal_yaml, f) + tmp_path = f.name + + try: + data = _load_package_changes(tmp_path) + assert len(data["revision_changes"]) == 1 + assert data["revision_changes"][0]["source"] == "old-pkg" + + # Ensure mtime changes (some filesystems have 1s resolution) + time.sleep(0.05) + os.utime(tmp_path, None) + + minimal_yaml["revision_changes"].append( + {"source": "another-pkg", "revision": 2} + ) + with open(tmp_path, "w") as f: + yaml.dump(minimal_yaml, f) + + data = _load_package_changes(tmp_path) + assert len(data["revision_changes"]) == 2 + assert data["revision_changes"][1]["source"] == "another-pkg" + finally: + os.unlink(tmp_path) diff --git a/uv.lock b/uv.lock index a2cc44d4..eacc3b64 100644 --- a/uv.lock +++ b/uv.lock @@ -40,10 +40,12 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fastapi-cache2" }, { name = "httpx" }, + { name = "jsonschema" }, { name = "podman" }, { name = "podman-compose" }, { name = "pydantic-settings" }, { name = "pynacl" }, + { name = "pyyaml" }, { name = "redis" }, { name = "rq" }, { name = "uvicorn" }, @@ -67,12 +69,14 @@ requires-dist = [ { name = "fastapi-cache2", specifier = ">=0.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "jsonschema", specifier = ">=4.0" }, { name = "podman", specifier = ">=5.6.0" }, { name = "podman-compose", specifier = ">=1.5.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pynacl", specifier = ">=1.6.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" }, { name = "pytest-httpserver", marker = "extra == 'dev'", specifier = ">=1.1.3" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "redis", specifier = ">=6.4.0" }, { name = "rq", specifier = ">=2.6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.9" }, @@ -80,6 +84,15 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -537,6 +550,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -942,6 +982,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1037,6 +1090,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { 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]] name = "rq" version = "2.6.1"