Skip to content

Commit 6f0e935

Browse files
Add script to sync bufbuild/protovalidate to pypi
1 parent a5e83a4 commit 6f0e935

File tree

6 files changed

+452
-9
lines changed

6 files changed

+452
-9
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ name: CI
22
on:
33
push:
44
branches: [main]
5-
tags: ['v*']
5+
tags: ["v*"]
66
pull_request:
77
branches: [main]
8-
schedule:
9-
- cron: '15 22 * * *'
108
workflow_dispatch: {} # support manual runs
119
permissions:
1210
contents: read
@@ -21,7 +19,7 @@ jobs:
2119
resolution: ["highest", "lowest-direct"]
2220
env:
2321
# Shared env variables for all the tests
24-
UV_RESOLUTION: '${{ matrix.resolution }}'
22+
UV_RESOLUTION: "${{ matrix.resolution }}"
2523
steps:
2624
- name: Checkout code
2725
uses: actions/checkout@v5

.github/workflows/protovalidate-gencode-pypi-sync.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55

66
permissions:
77
contents: read
8+
id-token: write
89

910
jobs:
1011
push-pypi:
@@ -21,7 +22,7 @@ jobs:
2122
- name: Install dependencies
2223
run: cd bufbuild-protovalidate-protocolbuffers && uv sync --frozen
2324
- name: Build and publish
24-
run: cd bufbuild-protovalidate-protocolbuffers && rm -rf dist && PROTOVALIDATE_VERSION="v0.11.0" make generate
25+
run: cd bufbuild-protovalidate-protocolbuffers && uv run sync_to_pypi.py --pypi-url https://test.pypi.org
2526
- name: Publish package distributions to TestPyPI
2627
# authorization is done via OIDC, so no token needed
2728
# Instead, we've set up a trusted publishing config for the pypi package, that maps to this repository and the
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.tmp/bin

bufbuild-protovalidate-protocolbuffers/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "bufbuild-protovalidate-protocolbuffers"
3-
version = "0.14.1"
3+
version = "0.14.0"
44
description = "Messages, enum types and stubs for buf.build/bufbuild/protovalidate for python."
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -22,7 +22,7 @@ dependencies = ["protobuf>=5"]
2222

2323

2424
[dependency-groups]
25-
dev = ["ruff>=0.12.12"]
25+
dev = ["cyclopts>=3.24.0", "httpx>=0.28.1", "loguru>=0.7.3", "ruff>=0.12.12"]
2626

2727

2828
[tool.ruff]
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Copyright 2023-2025 Buf Technologies, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# /// script
16+
# requires-python = ">=3.9"
17+
# dependencies = [
18+
# "cyclopts",
19+
# "httpx",
20+
# "loguru",
21+
# ]
22+
# ///
23+
24+
"""
25+
This script is used to sync the tagged versions of https://buf.build/bufbuild/protovalidate/ to the
26+
pypi package bufbuild-protovalidate-protocolbuffers.
27+
28+
Intended to run on a cron schedule, the script will query the available versions of the protovalidate
29+
module on buf.build and compare it to the versions available on pypi. If there are any versions on buf.build
30+
that are not on pypi, it will generate a python package for that version and save the wheels and sdists to
31+
the dist/ directory on the current path.
32+
33+
A follow-up publish step can then be configured to publish the packages in `dist/` to pypi.
34+
"""
35+
36+
import json
37+
from pathlib import Path
38+
import shutil
39+
import subprocess
40+
import sys
41+
import tempfile
42+
from typing import Optional, Set, Tuple
43+
from cyclopts import App
44+
import httpx
45+
from loguru import logger
46+
47+
app = App()
48+
49+
50+
def query_pypi_versions(pypi_url: str) -> set[str]:
51+
"""
52+
Query the PyPI JSON API for a list of published versions of the package.
53+
54+
Args:
55+
pypi_url: The base URL of the PyPI instance to query. For example, https://pypi.org/ or https://test.pypi.org/.
56+
57+
Returns:
58+
A set of version strings. For example, {"0.14.0", "0.14.1"}.
59+
"""
60+
pypi_url = pypi_url.rstrip("/")
61+
response = httpx.get(f"{pypi_url}/pypi/bufbuild-protovalidate-protocolbuffers/json")
62+
response.raise_for_status()
63+
return set(response.json()["releases"].keys())
64+
65+
66+
def query_buf_module_tags() -> Set[str]:
67+
"""
68+
Query the buf registry for a list of tags for the bufbuild/protovalidate module
69+
70+
Returns:
71+
A set of tag strings without the "v" prefix. For example, {"0.14.0", "0.14.1"}.
72+
"""
73+
tags = set()
74+
page_token = None
75+
while True: # paginate through the pages of results
76+
page_tags, page_token = _query_buf_module_labels(page_token)
77+
tags.update(page_tags)
78+
if page_token is None:
79+
break
80+
return tags
81+
82+
83+
def _query_buf_module_labels(page_token: Optional[str] = None) -> Tuple[Set[str], Optional[str]]:
84+
"""Fetch a single page of tags for the bufbuild/protovalidate module."""
85+
request_data = {
86+
"pageSize": 100,
87+
"resourceRef": {"name": {"owner": "bufbuild", "module": "protovalidate"}},
88+
"order": "ORDER_UPDATE_TIME_DESC",
89+
"archiveFilter": "ARCHIVE_FILTER_UNARCHIVED_ONLY",
90+
}
91+
if page_token:
92+
request_data["pageToken"] = page_token
93+
94+
response = httpx.post(
95+
"https://buf.build/buf.registry.module.v1beta1.LabelService/ListLabels",
96+
headers={"Content-Type": "application/json"},
97+
content=json.dumps(request_data),
98+
)
99+
response.raise_for_status()
100+
response_data = response.json()
101+
tags = {label["name"][1:] for label in response_data["labels"] if label["name"].startswith("v")}
102+
next_page_token = response_data.get("nextPageToken") # is not there on the last page
103+
return tags, next_page_token
104+
105+
106+
def _generate_package_for_version(version: str):
107+
"""
108+
Generate a python package for the given version of the bufbuild/protovalidate module.
109+
110+
Args:
111+
version: The bufbuild/protovalidate version to generate a python package for. Expected without the "v" prefix,
112+
for example, "0.14.0".
113+
"""
114+
logger.info(f"Generating package for version {version}")
115+
source_package_path = Path(__file__).parent.absolute()
116+
(source_package_path / "dist").mkdir(exist_ok=True)
117+
118+
with tempfile.TemporaryDirectory() as tmpdir:
119+
# since buf generate overwrites files checked into git, we generate a temporary working directory
120+
package_path = Path(tmpdir) / "bufbuild-protovalidate-protocolbuffers"
121+
shutil.copytree(source_package_path, package_path)
122+
123+
shutil.rmtree(package_path / "buf" / "validate" / "proto5")
124+
shutil.rmtree(package_path / "buf" / "validate" / "proto6")
125+
126+
buf_binary = package_path / ".tmp" / "bin" / "buf"
127+
subprocess.run([buf_binary, "generate", f"buf.build/bufbuild/protovalidate:v{version}"], cwd=package_path)
128+
129+
license_header_binary = package_path / ".tmp" / "bin" / "license-header"
130+
subprocess.run(
131+
[
132+
license_header_binary,
133+
"--license-type",
134+
"apache",
135+
"--copyright-holder",
136+
"Buf Technologies, Inc.",
137+
"--year-range",
138+
"2023-2025",
139+
],
140+
cwd=package_path,
141+
)
142+
subprocess.run(["uv", "version", f"v{version}"], cwd=package_path)
143+
subprocess.run(["uv", "build"], cwd=package_path)
144+
145+
build_output = package_path / "dist"
146+
if not build_output.exists():
147+
raise RuntimeError(
148+
f"Failed to build package for version {version}, no wheels or sdists found in {build_output}"
149+
)
150+
151+
for package in (package_path / "dist").iterdir():
152+
shutil.copy(package, source_package_path / "dist")
153+
154+
155+
@app.default
156+
def main(pypi_url: str = "https://pypi.org/"):
157+
logger.info(f"Querying pypi for existing versions of bufbuild-protovalidate-protocolbuffers (on pypi {pypi_url})")
158+
pypi_versions = query_pypi_versions(pypi_url)
159+
logger.info(f"Found {len(pypi_versions)} versions on pypi: {pypi_versions}")
160+
161+
logger.info("Querying buf.build for existing tags of bufbuild/protovalidate")
162+
buf_tags = query_buf_module_tags()
163+
logger.info(f"Found {len(buf_tags)} tags on buf.build: {buf_tags}")
164+
165+
existing_versions = pypi_versions & buf_tags
166+
logger.info(f"Found {len(existing_versions)} existing versions: {existing_versions}")
167+
versions_to_generate = buf_tags - pypi_versions
168+
logger.info(f"Found {len(versions_to_generate)} versions to generate: {versions_to_generate}")
169+
170+
for i, version in enumerate(versions_to_generate):
171+
if i >= 2:
172+
logger.info("Generated 2 packages, stopping for now to test the sync script.")
173+
return
174+
_generate_package_for_version(version)
175+
176+
177+
if __name__ == "__main__":
178+
# configure logging
179+
logger.remove()
180+
logger.add(sys.stdout, format="{time:%Y-%m-%d %H:%M:%S} | {level} | {message}", level="DEBUG")
181+
# invoke the CLI
182+
app()

0 commit comments

Comments
 (0)