diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index d4ca2a2..f185b35 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -7,7 +7,7 @@ on: - main env: - PACKAGES: checkup checkup-git checkup-dbt checkup-python checkup-conveyor checkup-airflow checkup-github checkup-gitlab checkup-bitbucket + PACKAGES: checkup checkup-git checkup-cruft checkup-dbt checkup-python checkup-conveyor checkup-airflow checkup-github checkup-gitlab checkup-bitbucket jobs: finalize: diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 3fa4bef..ceedf7c 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -10,6 +10,7 @@ on: options: - checkup - checkup-git + - checkup-cruft - checkup-dbt - checkup-python - checkup-conveyor diff --git a/plugins/checkup-cruft/README.md b/plugins/checkup-cruft/README.md new file mode 100644 index 0000000..e15796a --- /dev/null +++ b/plugins/checkup-cruft/README.md @@ -0,0 +1,101 @@ +# checkup-cruft + +Cruft template metrics plugin for [checkup](https://pypi.org/project/checkup/). + +Tracks how well a project stays in sync with its [cruft](https://cruft.github.io/cruft/) (cookiecutter) template. + +## Installation + +```bash +pip install checkup-cruft +``` + +## Requirements + +- Python >= 3.12 +- [checkup](https://pypi.org/project/checkup/) +- Git installed on the system +- Network access to the template repository (only for the drift metrics) + +## Usage + +```python +from checkup import CheckHub +from checkup_cruft import ( + CruftProvider, + CruftLinkedMetric, + CruftDaysSinceUpdateMetric, + CruftConflictCountMetric, + CruftUpToDateMetric, +) + +results = ( + CheckHub() + .with_metrics([ + CruftLinkedMetric(), + CruftDaysSinceUpdateMetric(), + CruftConflictCountMetric(), + CruftUpToDateMetric(), + ]) + .with_providers([[ + CruftProvider(project_path="./my_product", fetch_template=True), + ]]) + .measure() +) +``` + +## Provider + +### CruftProvider + +Reads `.cruft.json` from the project. With `fetch_template=True` it also clones the template repository +to compare the pinned commit against the template head; leave it off (the default) for a fully local, +offline run, in which case the drift metrics report `None`. + +## Available Metrics + +### Local Metrics + +#### CruftLinkedMetric + +Whether a `.cruft.json` template link is present. + +#### CruftDaysSinceUpdateMetric + +Days since `.cruft.json` last changed in git. + +#### CruftConflictCountMetric + +Number of `*.rej` files left by a failed `cruft update`. + +### Template Drift Metrics + +These require the provider to run with `fetch_template=True`. + +#### CruftUpToDateMetric + +Whether the pinned commit matches the latest template commit. + +#### CruftCommitsBehindMetric + +Number of template commits between the pinned commit and the head. + +#### CruftDaysBehindTemplateMetric + +Days between the pinned commit and the template head. + +## Creating Custom Metrics + +Extend `CruftMetric` to read the cruft context directly: + +```python +from checkup_cruft import CruftMetric + +class TemplateUrlMetric(CruftMetric): + name = "cruft_template_url" + description = "Configured cruft template URL" + + def calculate(self, context, measurements): + cruft = self.get_context(context) + return self.measure(value=cruft.get("template")) +``` diff --git a/plugins/checkup-cruft/pyproject.toml b/plugins/checkup-cruft/pyproject.toml new file mode 100644 index 0000000..f92a29c --- /dev/null +++ b/plugins/checkup-cruft/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "checkup-cruft" +version = "0.1.0" +description = "Cruft template metrics for checkup" +readme = "README.md" +requires-python = ">=3.12" +dependencies = ["checkup"] + +[project.urls] +Homepage = "https://github.com/datamindedbe/checkup" +Source = "https://github.com/datamindedbe/checkup" +Issues = "https://github.com/datamindedbe/checkup/issues" + +[dependency-groups] +dev = ["pytest>=8.0"] + +[tool.uv.sources] +checkup = { workspace = true } + +[project.entry-points."checkup.providers"] +cruft = "checkup_cruft:CruftProvider" + +[project.entry-points."checkup.metrics"] +cruft_linked = "checkup_cruft:CruftLinkedMetric" +cruft_days_since_update = "checkup_cruft:CruftDaysSinceUpdateMetric" +cruft_conflicts = "checkup_cruft:CruftConflictCountMetric" +cruft_commits_behind = "checkup_cruft:CruftCommitsBehindMetric" +cruft_up_to_date = "checkup_cruft:CruftUpToDateMetric" +cruft_days_behind_template = "checkup_cruft:CruftDaysBehindTemplateMetric" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/checkup_cruft"] diff --git a/plugins/checkup-cruft/src/checkup_cruft/__init__.py b/plugins/checkup-cruft/src/checkup_cruft/__init__.py new file mode 100644 index 0000000..e61c348 --- /dev/null +++ b/plugins/checkup-cruft/src/checkup_cruft/__init__.py @@ -0,0 +1,21 @@ +from checkup_cruft.metrics import ( + CruftCommitsBehindMetric, + CruftConflictCountMetric, + CruftDaysBehindTemplateMetric, + CruftDaysSinceUpdateMetric, + CruftLinkedMetric, + CruftMetric, + CruftUpToDateMetric, +) +from checkup_cruft.provider import CruftProvider + +__all__ = [ + "CruftProvider", + "CruftMetric", + "CruftLinkedMetric", + "CruftDaysSinceUpdateMetric", + "CruftConflictCountMetric", + "CruftCommitsBehindMetric", + "CruftUpToDateMetric", + "CruftDaysBehindTemplateMetric", +] diff --git a/plugins/checkup-cruft/src/checkup_cruft/metrics.py b/plugins/checkup-cruft/src/checkup_cruft/metrics.py new file mode 100644 index 0000000..37183e5 --- /dev/null +++ b/plugins/checkup-cruft/src/checkup_cruft/metrics.py @@ -0,0 +1,128 @@ +from datetime import UTC, datetime + +from checkup.measurement import Measurement, Measurements +from checkup.metric import Metric +from checkup.provider import Provider +from checkup.types import Context +from checkup_cruft.provider import CruftProvider + + +class CruftMetric(Metric): + """ + Base class for cruft-related metrics. + """ + + @classmethod + def providers(cls) -> list[type[Provider]]: + return [CruftProvider] + + def get_context(self, context: Context) -> dict: + return context.get(CruftProvider.name, {}) + + +class CruftLinkedMetric(CruftMetric): + """ + Whether the project is linked to a cruft template. + """ + + name: str = "cruft_linked" + description: str = "Whether a .cruft.json template link is present" + unit: str = "boolean" + + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + cruft = self.get_context(context) + return self.measure(value=1 if cruft.get("present") else 0) + + +class CruftDaysSinceUpdateMetric(CruftMetric): + """ + Days since the cruft template link (.cruft.json) was last updated. + """ + + name: str = "cruft_days_since_update" + description: str = "Days since the last cruft template update" + unit: str = "days" + + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + cruft = self.get_context(context) + last_update = cruft.get("last_update_date") + if not isinstance(last_update, datetime): + return self.measure(value=None, diagnostic="No .cruft.json found") + delta = datetime.now(UTC) - last_update + return self.measure( + value=delta.days, + diagnostic=f"Last cruft update: {last_update.strftime('%Y-%m-%d')}", + ) + + +class CruftConflictCountMetric(CruftMetric): + """ + Number of unresolved cruft template conflicts (*.rej files). + """ + + name: str = "cruft_conflicts" + description: str = "Number of unresolved cruft template conflicts" + unit: str = "files" + + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + cruft = self.get_context(context) + conflicts = cruft.get("conflict_files", []) + return self.measure(value=len(conflicts), diagnostic=", ".join(conflicts)) + + +class CruftCommitsBehindMetric(CruftMetric): + """ + Template commits between the pinned commit and the template head. + + Requires the provider to run with fetch_template=True. + """ + + name: str = "cruft_commits_behind" + description: str = "Number of template commits the project is behind" + unit: str = "commits" + + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + cruft = self.get_context(context) + behind = cruft.get("commits_behind") + if behind is None: + return self.measure(value=None, diagnostic="Template not fetched") + return self.measure(value=behind) + + +class CruftUpToDateMetric(CruftMetric): + """ + Whether the pinned template commit matches the template head. + + Requires the provider to run with fetch_template=True. + """ + + name: str = "cruft_up_to_date" + description: str = "Whether the project matches the latest template commit" + unit: str = "boolean" + + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + cruft = self.get_context(context) + behind = cruft.get("commits_behind") + if behind is None: + return self.measure(value=None, diagnostic="Template not fetched") + return self.measure(value=1 if behind == 0 else 0) + + +class CruftDaysBehindTemplateMetric(CruftMetric): + """ + Days between the pinned template commit and the latest template commit. + + Requires the provider to run with fetch_template=True. + """ + + name: str = "cruft_days_behind_template" + description: str = "Days between the pinned commit and the template head" + unit: str = "days" + + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + cruft = self.get_context(context) + pinned = cruft.get("pinned_date") + head = cruft.get("head_date") + if not isinstance(pinned, datetime) or not isinstance(head, datetime): + return self.measure(value=None, diagnostic="Template not fetched") + return self.measure(value=(head - pinned).days) diff --git a/plugins/checkup-cruft/src/checkup_cruft/provider.py b/plugins/checkup-cruft/src/checkup_cruft/provider.py new file mode 100644 index 0000000..900f761 --- /dev/null +++ b/plugins/checkup-cruft/src/checkup_cruft/provider.py @@ -0,0 +1,125 @@ +import json +import logging +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any, ClassVar + +from checkup.provider import Provider + +logger = logging.getLogger(__name__) + +CRUFT_FILE = ".cruft.json" + + +class CruftProvider(Provider): + """ + Provides cruft template context. + + With fetch_template=True it also clones the template to measure drift. + """ + + name: ClassVar[str] = "cruft" + + def __init__(self, project_path: str | Path = ".", fetch_template: bool = False): + self.project_path = Path(project_path) + self.fetch_template = fetch_template + + def provide(self) -> dict[str, Any]: + cruft_path = self.project_path / CRUFT_FILE + if not cruft_path.exists(): + return {"present": False} + + config = json.loads(cruft_path.read_text()) + template = config.get("template") + commit = config.get("commit") + + context: dict[str, Any] = { + "present": True, + "template": template, + "commit": commit, + "directory": config.get("directory"), + "last_update_date": self._last_update_date(), + "conflict_files": self._conflict_files(), + } + + if self.fetch_template and template and commit: + context.update( + self._template_drift(template, commit, config.get("checkout")) + ) + + return context + + def _last_update_date(self) -> datetime | None: + """ + Author date of the most recent commit touching .cruft.json. + """ + + result = subprocess.run( + ["git", "log", "-1", "--format=%aI", "--", CRUFT_FILE], + cwd=self.project_path, + capture_output=True, + text=True, + ) + date_str = result.stdout.strip() + return datetime.fromisoformat(date_str) if date_str else None + + def _conflict_files(self) -> list[str]: + """ + Reject files a failed `cruft update` leaves behind. + """ + + result = subprocess.run( + ["git", "ls-files", "-co", "--exclude-standard", "-z", "--", "*.rej"], + cwd=self.project_path, + capture_output=True, + text=True, + ) + return [f for f in result.stdout.split("\0") if f] + + def _template_drift( + self, + template: str, + pinned: str, + checkout: str | None, + ) -> dict[str, Any]: + """ + Clone the template and measure how far the pinned commit lags its head. + """ + + try: + with tempfile.TemporaryDirectory() as tmp: + subprocess.run( + ["git", "clone", "--quiet", template, tmp], + check=True, + capture_output=True, + text=True, + ) + head = self._git(tmp, "rev-parse", checkout or "HEAD") + behind = int(self._git(tmp, "rev-list", "--count", f"{pinned}..{head}")) + + return { + "template_head": head, + "commits_behind": behind, + "pinned_date": self._commit_date(tmp, pinned), + "head_date": self._commit_date(tmp, head), + } + except (subprocess.CalledProcessError, ValueError) as exc: + logger.warning(f"Could not fetch cruft template {template}: {exc}") + return {} + + def _commit_date(self, repo: str, ref: str) -> datetime | None: + try: + return datetime.fromisoformat( + self._git(repo, "show", "-s", "--format=%cI", ref) + ) + except (subprocess.CalledProcessError, ValueError): + return None + + @staticmethod + def _git(repo: str, *args: str) -> str: + result = subprocess.run( + ["git", "-C", repo, *args], check=True, capture_output=True, text=True + ) + return result.stdout.strip() diff --git a/plugins/checkup-cruft/tests/conftest.py b/plugins/checkup-cruft/tests/conftest.py new file mode 100644 index 0000000..76f3a59 --- /dev/null +++ b/plugins/checkup-cruft/tests/conftest.py @@ -0,0 +1,91 @@ +import json +import subprocess +from pathlib import Path + +import pytest + + +def _git(repo: Path, *args: str, date: str | None = None) -> str: + env = None + if date: + env = {"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date} + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + env={**_base_env(), **env} if env else None, + ) + return result.stdout.strip() + + +def _base_env() -> dict[str, str]: + import os + + return os.environ.copy() + + +def _commit(repo: Path, message: str, *, date: str | None = None) -> str: + _git(repo, "add", ".") + _git( + repo, + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "-m", + message, + date=date, + ) + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture +def template_repo(tmp_path: Path) -> Path: + """ + A local git repo standing in for a cruft template, with three commits. + """ + + repo = tmp_path / "template" + repo.mkdir() + _git(repo, "init", "-q") + for i, date in enumerate(["2020-01-01", "2021-01-01", "2022-01-01"]): + (repo / "file.txt").write_text(str(i)) + _commit(repo, f"c{i}", date=f"{date}T00:00:00") + return repo + + +@pytest.fixture +def make_product(tmp_path: Path): + """ + Build a product repo with a .cruft.json pinned to a given template commit. + """ + + def _make( + template: Path | None = None, + pinned: str | None = None, + *, + conflicts: int = 0, + commit_cruft: bool = True, + ) -> Path: + repo = tmp_path / "product" + repo.mkdir() + _git(repo, "init", "-q") + (repo / "readme.md").write_text("x") + _commit(repo, "init") + + if template is not None: + (repo / ".cruft.json").write_text( + json.dumps( + {"template": str(template), "commit": pinned, "checkout": None} + ) + ) + for n in range(conflicts): + (repo / f"file{n}.py.rej").write_text("conflict") + if commit_cruft: + _commit(repo, "add cruft") + return repo + + return _make diff --git a/plugins/checkup-cruft/tests/test_cruft_metrics.py b/plugins/checkup-cruft/tests/test_cruft_metrics.py new file mode 100644 index 0000000..3efb7dc --- /dev/null +++ b/plugins/checkup-cruft/tests/test_cruft_metrics.py @@ -0,0 +1,118 @@ +from pathlib import Path + +from checkup_cruft import ( + CruftCommitsBehindMetric, + CruftConflictCountMetric, + CruftDaysBehindTemplateMetric, + CruftDaysSinceUpdateMetric, + CruftLinkedMetric, + CruftProvider, + CruftUpToDateMetric, +) + +from checkup.hub import CheckHub + + +def _measure(repo: Path, metric, *, fetch_template: bool = False): + result = ( + CheckHub() + .with_metrics([metric]) + .with_providers( + [[CruftProvider(project_path=repo, fetch_template=fetch_template)]] + ) + .measure() + ) + assert len(result.errors) == 0, f"Errors: {result.errors}" + return next(m for m in result.measurements if m.metric.name == metric.name) + + +def test_linked_true(make_product, template_repo): + repo = make_product(template_repo, "deadbeef") + assert _measure(repo, CruftLinkedMetric()).value == 1 + + +def test_linked_false(make_product): + repo = make_product() + assert _measure(repo, CruftLinkedMetric()).value == 0 + + +def test_days_since_update_fresh(make_product, template_repo): + repo = make_product(template_repo, "deadbeef") + assert _measure(repo, CruftDaysSinceUpdateMetric()).value == 0 + + +def test_days_since_update_none_without_cruft(make_product): + repo = make_product() + assert _measure(repo, CruftDaysSinceUpdateMetric()).value is None + + +def test_conflicts_counted(make_product, template_repo): + repo = make_product(template_repo, "deadbeef", conflicts=2) + measurement = _measure(repo, CruftConflictCountMetric()) + assert measurement.value == 2 + assert ".rej" in measurement.diagnostic + + +def test_no_conflicts(make_product, template_repo): + repo = make_product(template_repo, "deadbeef") + assert _measure(repo, CruftConflictCountMetric()).value == 0 + + +def test_conflicts_finds_untracked_and_skips_gitignored(make_product, template_repo): + repo = make_product(template_repo, "deadbeef") + (repo / "model.sql.rej").write_text("x") # untracked, must count + (repo / ".gitignore").write_text("node_modules/\n") + (repo / "node_modules").mkdir() + (repo / "node_modules" / "dep.rej").write_text("x") # ignored, must not count + measurement = _measure(repo, CruftConflictCountMetric()) + assert measurement.value == 1 + assert "model.sql.rej" in measurement.diagnostic + + +def _first_commit(repo: Path) -> str: + import subprocess + + return subprocess.run( + ["git", "-C", str(repo), "rev-list", "--max-parents=0", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def test_commits_behind(make_product, template_repo): + pinned = _first_commit(template_repo) # three commits total -> 2 behind + repo = make_product(template_repo, pinned) + assert _measure(repo, CruftCommitsBehindMetric(), fetch_template=True).value == 2 + + +def test_up_to_date_when_pinned_to_head(make_product, template_repo): + import subprocess + + head = subprocess.run( + ["git", "-C", str(template_repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + repo = make_product(template_repo, head) + assert _measure(repo, CruftUpToDateMetric(), fetch_template=True).value == 1 + + +def test_up_to_date_false_when_behind(make_product, template_repo): + repo = make_product(template_repo, _first_commit(template_repo)) + assert _measure(repo, CruftUpToDateMetric(), fetch_template=True).value == 0 + + +def test_days_behind_template(make_product, template_repo): + # first commit 2020-01-01, head 2022-01-01 -> ~730 days + repo = make_product(template_repo, _first_commit(template_repo)) + value = _measure(repo, CruftDaysBehindTemplateMetric(), fetch_template=True).value + assert value >= 700 + + +def test_drift_none_without_fetch(make_product, template_repo): + repo = make_product(template_repo, _first_commit(template_repo)) + assert ( + _measure(repo, CruftCommitsBehindMetric(), fetch_template=False).value is None + ) diff --git a/pyproject.toml b/pyproject.toml index a24d228..6dec1be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ build-backend = "uv_build" [tool.uv.workspace] members = [ "plugins/checkup-git", + "plugins/checkup-cruft", "plugins/checkup-dbt", "plugins/checkup-python", "plugins/checkup-conveyor", diff --git a/uv.lock b/uv.lock index 2848640..55a1389 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ members = [ "checkup-airflow", "checkup-bitbucket", "checkup-conveyor", + "checkup-cruft", "checkup-dbt", "checkup-git", "checkup-github", @@ -253,6 +254,25 @@ requires-dist = [ { name = "requests" }, ] +[[package]] +name = "checkup-cruft" +version = "0.1.0" +source = { editable = "plugins/checkup-cruft" } +dependencies = [ + { name = "checkup" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "checkup", editable = "." }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "checkup-dbt" version = "0.4.1" @@ -659,6 +679,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -667,6 +688,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -675,6 +697,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -683,6 +706,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },