Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ jobs:
- name: Test
run: "pants test ::"

- name: Check Pyrefly version pins are current
env:
GITHUB_TOKEN: ${{ github.token }}
run: "python3 build-support/bin/generate_known_versions.py --check"

- name: Package (build the wheel + sdist)
run: "pants package pants-plugins/pants_pyrefly:dist"

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
All notable changes to `pants-pyrefly` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## 0.3.0 (unreleased)

- Pyrefly version pins (`default_known_versions`) are now generated by
`build-support/bin/generate_known_versions.py` instead of being hand-edited: it fetches each
asset's published `.sha256` and size from the GitHub release. Bump with `--version <new> --write`;
CI runs `--check` to fail if the committed pins drift from the release.

## 0.2.0 (2026-07-10)

- `pants pyrefly-suppress` inserts inline `# pyrefly: ignore` comments for the current errors in the
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ pants test :: # run the integration tests
pants package pants-plugins/pants_pyrefly:dist # build the wheel + sdist into dist/
```

### Bumping the pinned Pyrefly version

The four `default_known_versions` pins in `subsystems.py` (`<version>|<platform>|<sha256>|<size>`)
are generated, not hand-edited. To move to a new Pyrefly release:

```bash
python3 build-support/bin/generate_known_versions.py --version <new> --write
```

It reads the URL template and platform mapping straight from `subsystems.py`, fetches each asset's
published `.sha256` sidecar and size from the GitHub release, and rewrites `default_version` + the
pins. CI runs the same script with `--check` and fails if the committed pins drift from what the
release actually publishes. (Set `GITHUB_TOKEN` to avoid GitHub API rate limits.)

## Releasing

Push a `vX.Y.Z` tag. The [release workflow](.github/workflows/release.yml) builds the wheel and
Expand Down
14 changes: 14 additions & 0 deletions build-support/bin/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2026 Tague Griffith
# Licensed under the Apache License, Version 2.0 (see LICENSE).

# Stdlib-only maintenance scripts. Not part of the published plugin (they live outside the
# `pants_pyrefly` package), so they never enter the wheel.

python_sources(name="bin")

python_tests(
name="tests",
# The generator/test run outside a Pants sandbox in CI (plain `python3`); the test itself is
# pure-stdlib + pytest and needs no network.
skip_pyrefly=True,
)
208 changes: 208 additions & 0 deletions build-support/bin/generate_known_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
# Copyright 2026 Tague Griffith
# Licensed under the Apache License, Version 2.0 (see LICENSE).

"""Generate or verify the Pyrefly `default_known_versions` pins in `subsystems.py`.

Each pin is `"<version>|<pants_platform>|<sha256>|<size_bytes>"`. Rather than hand-computing
four SHA256/size pairs on every Pyrefly bump, this reads the plugin's own `subsystems.py`
(via `ast`, so the script and the plugin can never disagree on the version, URL template, or
platform mapping), fetches the published `.sha256` sidecars and asset sizes from the
facebook/pyrefly GitHub release, and emits the pins.

Usage (run directly; pure stdlib, no Pants required):

GEN=build-support/bin/generate_known_versions.py
python3 $GEN # print pins for the current version
python3 $GEN --version 1.2.0 # print pins for a specific version
python3 $GEN --write # rewrite subsystems.py in place
python3 $GEN --check # CI: fail if the committed pins are stale

Set `GITHUB_TOKEN` (or pass `--token`) to raise the GitHub API rate limit.
"""

from __future__ import annotations

import argparse
import ast
import hashlib
import json
import os
import sys
import urllib.request
from dataclasses import dataclass
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SUBSYSTEMS = REPO_ROOT / "pants-plugins" / "pants_pyrefly" / "subsystems.py"
RELEASE_API = "https://api.github.com/repos/facebook/pyrefly/releases/tags/{version}"


@dataclass
class PluginConfig:
"""The Pyrefly download config parsed out of `subsystems.py`."""

version: str
url_template: str
platform_mapping: dict[str, str]
known_versions: list[str]
# 1-based inclusive line spans of the two assignments we rewrite with `--write`.
version_span: tuple[int, int]
known_versions_span: tuple[int, int]


def _find_class(tree: ast.Module, name: str) -> ast.ClassDef:
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == name:
return node
raise ValueError(f"class `{name}` not found")


def _assignments(class_node: ast.ClassDef) -> dict[str, ast.Assign]:
out: dict[str, ast.Assign] = {}
for node in class_node.body:
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
if isinstance(target, ast.Name):
out[target.id] = node
return out


def parse_plugin_config(text: str) -> PluginConfig:
tree = ast.parse(text)
assigns = _assignments(_find_class(tree, "Pyrefly"))
for required in (
"default_version",
"default_url_template",
"default_url_platform_mapping",
"default_known_versions",
):
if required not in assigns:
raise ValueError(f"`Pyrefly.{required}` not found in subsystems.py")

version_node = assigns["default_version"]
known_node = assigns["default_known_versions"]
return PluginConfig(
version=ast.literal_eval(version_node.value),
url_template=ast.literal_eval(assigns["default_url_template"].value),
platform_mapping=ast.literal_eval(assigns["default_url_platform_mapping"].value),
known_versions=ast.literal_eval(known_node.value),
version_span=(version_node.lineno, version_node.end_lineno or version_node.lineno),
known_versions_span=(known_node.lineno, known_node.end_lineno or known_node.lineno),
)


def _get(url: str, token: str | None) -> bytes:
headers = {"User-Agent": "pants-pyrefly-known-versions"}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request) as response:
return response.read()


def _asset_filename(url_template: str, version: str, url_platform: str) -> str:
return url_template.format(version=version, platform=url_platform).rsplit("/", 1)[-1]


def compute_known_versions(config: PluginConfig, version: str, token: str | None) -> list[str]:
"""Fetch sha256 + size for each mapped platform and return the pin lines, in mapping order."""
release = json.loads(_get(RELEASE_API.format(version=version), token))
sizes = {asset["name"]: asset["size"] for asset in release.get("assets", [])}
downloads = {
asset["name"]: asset["browser_download_url"] for asset in release.get("assets", [])
}

pins: list[str] = []
for pants_platform, url_platform in config.platform_mapping.items():
filename = _asset_filename(config.url_template, version, url_platform)
if filename not in downloads:
raise ValueError(f"release {version} has no asset named `{filename}`")
asset_url = downloads[filename]

try:
# Cheap path: read the published `<asset>.sha256` sidecar (first token is the digest).
sha256 = _get(f"{asset_url}.sha256", token).split()[0].decode()
size = sizes[filename]
except Exception:
# Fallback: download the asset and compute both locally.
blob = _get(asset_url, token)
sha256 = hashlib.sha256(blob).hexdigest()
size = len(blob)
pins.append(f"{version}|{pants_platform}|{sha256}|{size}")
return pins


def render_known_versions_block(pins: list[str]) -> list[str]:
lines = [" default_known_versions = ["]
lines += [f' "{pin}",' for pin in pins]
lines.append(" ]")
return lines


def rewrite_subsystems(path: Path, config: PluginConfig, version: str, pins: list[str]) -> None:
lines = path.read_text().splitlines()
# Replace the later assignment first so the earlier span's line numbers stay valid.
kv_start, kv_end = config.known_versions_span
lines[kv_start - 1 : kv_end] = render_known_versions_block(pins)
v_start, v_end = config.version_span
lines[v_start - 1 : v_end] = [f' default_version = "{version}"']
path.write_text("\n".join(lines) + "\n")


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--subsystems",
type=Path,
default=DEFAULT_SUBSYSTEMS,
help="Path to subsystems.py (default: the plugin's).",
)
parser.add_argument(
"--version",
default=None,
help="Pyrefly version to pin (default: the current `default_version`).",
)
parser.add_argument("--write", action="store_true", help="Rewrite subsystems.py in place.")
parser.add_argument(
"--check",
action="store_true",
help="Exit non-zero if the current pins are stale (for CI). Ignores --version.",
)
parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"), help="GitHub API token.")
args = parser.parse_args(argv)

config = parse_plugin_config(args.subsystems.read_text())

if args.check:
pins = compute_known_versions(config, config.version, args.token)
if pins != config.known_versions:
print(
f"Pyrefly pins are stale for version {config.version}.\n"
"Regenerate with `python3 build-support/bin/generate_known_versions.py --write`.\n",
file=sys.stderr,
)
print("expected:", file=sys.stderr)
for pin in pins:
print(f" {pin}", file=sys.stderr)
print("found:", file=sys.stderr)
for pin in config.known_versions:
print(f" {pin}", file=sys.stderr)
return 1
print(f"Pyrefly pins are current for version {config.version}.")
return 0

version = args.version or config.version
pins = compute_known_versions(config, version, args.token)

if args.write:
rewrite_subsystems(args.subsystems, config, version, pins)
print(f"Wrote {len(pins)} Pyrefly {version} pin(s) to {args.subsystems}.")
return 0

print("\n".join(render_known_versions_block(pins)))
return 0


if __name__ == "__main__":
raise SystemExit(main())
99 changes: 99 additions & 0 deletions build-support/bin/generate_known_versions_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Copyright 2026 Tague Griffith
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

import json
from pathlib import Path

import generate_known_versions as gkv

_SUBSYSTEMS = """\
class Pyrefly(TemplatedExternalTool):
options_scope = "pyrefly"

default_version = "1.1.1"
default_url_template = (
"https://github.com/facebook/pyrefly/releases/download/{version}/pyrefly-{platform}.tar.gz"
)
default_url_platform_mapping = {
"macos_arm64": "macos-arm64",
"linux_x86_64": "linux-x86_64-musl",
}
default_known_versions = [
"1.1.1|macos_arm64|aaaa|111",
"1.1.1|linux_x86_64|bbbb|222",
]

skip = SkipOption("check")
"""


def test_parse_plugin_config() -> None:
config = gkv.parse_plugin_config(_SUBSYSTEMS)
assert config.version == "1.1.1"
assert config.platform_mapping == {
"macos_arm64": "macos-arm64",
"linux_x86_64": "linux-x86_64-musl",
}
assert config.known_versions == [
"1.1.1|macos_arm64|aaaa|111",
"1.1.1|linux_x86_64|bbbb|222",
]


def _fake_get(monkeypatch, version: str) -> None:
# Asset name -> (size, sha256 sidecar bytes).
assets = {
"pyrefly-macos-arm64.tar.gz": (12345, b"deadbeef pyrefly-macos-arm64.tar.gz\n"),
"pyrefly-linux-x86_64-musl.tar.gz": (
67890,
b"cafef00d pyrefly-linux-x86_64-musl.tar.gz\n",
),
}
release = {
"assets": [
{"name": name, "size": size, "browser_download_url": f"https://dl/{name}"}
for name, (size, _sha) in assets.items()
]
}

def fake_get(url: str, token: str | None) -> bytes:
if url == gkv.RELEASE_API.format(version=version):
return json.dumps(release).encode()
for name, (_size, sha) in assets.items():
if url == f"https://dl/{name}.sha256":
return sha
raise AssertionError(f"unexpected url {url}")

monkeypatch.setattr(gkv, "_get", fake_get)


def test_compute_known_versions(monkeypatch) -> None:
_fake_get(monkeypatch, "1.1.1")
config = gkv.parse_plugin_config(_SUBSYSTEMS)
pins = gkv.compute_known_versions(config, "1.1.1", token=None)
# Emitted in platform-mapping order, sha256 from the sidecar, size from the release API.
assert pins == [
"1.1.1|macos_arm64|deadbeef|12345",
"1.1.1|linux_x86_64|cafef00d|67890",
]


def test_write_updates_version_and_pins(monkeypatch, tmp_path: Path) -> None:
_fake_get(monkeypatch, "2.0.0")
path = tmp_path / "subsystems.py"
path.write_text(_SUBSYSTEMS)
config = gkv.parse_plugin_config(path.read_text())
pins = gkv.compute_known_versions(config, "2.0.0", token=None)
gkv.rewrite_subsystems(path, config, "2.0.0", pins)

# The rewrite is parseable and reflects the new version + pins.
reparsed = gkv.parse_plugin_config(path.read_text())
assert reparsed.version == "2.0.0"
assert reparsed.known_versions == [
"2.0.0|macos_arm64|deadbeef|12345",
"2.0.0|linux_x86_64|cafef00d|67890",
]
# Surrounding lines are preserved.
assert 'skip = SkipOption("check")' in path.read_text()
1 change: 1 addition & 0 deletions pants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ indexes = [
root_patterns = [
"/pants-plugins",
"/testprojects/src",
"/build-support/bin",
]