Skip to content
Closed
139 changes: 139 additions & 0 deletions .github/scripts/check_datadogpy_vendor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Compare the vendored datadogpy version with the latest release on PyPI.

Values written to ``$GITHUB_OUTPUT`` (GitHub Actions inter-step data):
outdated - ``"true"`` when the PyPI release is strictly newer than the vendored pin, ``"false"`` otherwise
latest_version - latest version string fetched from PyPI (e.g. ``"0.53.0"``)
vendored_version - version string parsed from ``ddtrace/vendor/__init__.py`` (e.g. ``"0.52.1"``)

Exit codes:
0 - completed successfully (regardless of whether the vendored copy is outdated)
1 - unexpected error (e.g. PyPI unreachable, version string not parseable)
"""

from __future__ import annotations

import json
import os
from pathlib import Path
import re
import sys
import urllib.error
import urllib.request


# Use the copy of `packaging` that is already vendored in this repository so
# the workflow step works without a separate `pip install` and the dependency
# version is pinned alongside the rest of the codebase.
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "ddtrace" / "vendor"))

from packaging.version import InvalidVersion # noqa: E402
from packaging.version import Version # noqa: E402


PYPI_URL: str = "https://pypi.org/pypi/datadog/json"
VENDOR_INIT: Path = Path(__file__).parent.parent.parent / "ddtrace" / "vendor" / "__init__.py"


def _set_output(name: str, value: str) -> None:
"""Append ``name=value`` to the GitHub Actions step-output file.

When run locally (``$GITHUB_OUTPUT`` is unset) the value is printed to
stdout instead so the output is still visible.
"""
github_output: str | None = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as fh:
fh.write(f"{name}={value}\n")
else:
print(f" {name}={value}")


def _latest_pypi_version() -> str:
try:
req: urllib.request.Request = urllib.request.Request(PYPI_URL)
with urllib.request.urlopen(req, timeout=10) as resp: # nosec B310
data: dict[str, dict[str, str]] = json.load(resp)
version: str = data["info"]["version"]
return version
except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc:
print(f"ERROR: could not fetch latest datadogpy version from PyPI: {exc}", file=sys.stderr)
sys.exit(1)


def _vendored_version() -> str:
try:
content: str = VENDOR_INIT.read_text()
except FileNotFoundError:
print(f"ERROR: {VENDOR_INIT} not found", file=sys.stderr)
sys.exit(1)

# Extract just the dogstatsd section (from its header up to the next package header
# or end of file) so we don't accidentally pick up a Version: line from another
# vendored package. Each section starts with "<name>\n---+\n"; the lookahead
# matches that two-line opener to stop before the next section begins.
section_match: re.Match[str] | None = re.search(
r"^dogstatsd\n-+\n(.*?)(?=\n\w[^\n]*\n-+\n|\Z)",
content,
re.DOTALL | re.MULTILINE,
)
if not section_match:
print(f"ERROR: dogstatsd section not found in {VENDOR_INIT}", file=sys.stderr)
sys.exit(1)

section: str = section_match.group(1)

# Accept both formats, preserving any PEP 440 suffix (rc, post, dev, …):
# "Version: 0.52.1" (clean release after the vendor bump)
# "Version: 0.52.1rc1" (hypothetical pre-release pin)
# "Version: 8e11af2 (0.39.1)" (git-hash + version in parens, legacy)
# The character class [^\s)] stops at whitespace or a closing paren so the
# parenthesised form is handled without a separate branch.
version_match: re.Match[str] | None = re.search(
r"Version:.*?(\d+\.\d+\.\d+[^\s)]*)",
section,
)
if not version_match:
print(f"ERROR: could not parse vendored datadogpy version from {VENDOR_INIT}", file=sys.stderr)
sys.exit(1)
return version_match.group(1)


def _is_outdated(vendored: str, latest: str) -> bool:
"""Return True when the PyPI release is strictly newer than the vendored pin.

Uses ``packaging.version.Version`` for correct PEP 440 ordering so that
post-releases, pre-releases, and dev releases all compare properly::

"0.53.0.post1" > "0.53.0" → True (post-release is newer)
"0.53.0rc1" < "0.53.0" → False (pre-release is older)
"0.44.1.dev0" < "0.44.1" → False (dev release is older)
"0.53.0" == "0.53.0" → False (same)
"""
try:
return Version(latest) > Version(vendored)
except InvalidVersion as exc:
print(f"ERROR: cannot compare versions {vendored!r} and {latest!r}: {exc}", file=sys.stderr)
sys.exit(1)


def main() -> None:
latest: str = _latest_pypi_version()
vendored: str = _vendored_version()

print(f"Vendored datadogpy version : {vendored}")
print(f"Latest PyPI version : {latest}")

outdated: bool = _is_outdated(vendored, latest)
if outdated:
print("⚠ Vendored version is behind PyPI — consider a vendor bump.")
else:
print("✓ Vendored version is up to date.")

_set_output("outdated", str(outdated).lower())
_set_output("latest_version", latest)
_set_output("vendored_version", vendored)


if __name__ == "__main__":
main()
87 changes: 87 additions & 0 deletions .github/workflows/check-vendor-datadogpy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Check vendored datadogpy version

# Runs weekly and on demand. Opens (or updates) a GitHub issue when the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the job won't fail but an issue will be filed, correct?

Do we check those issues regularly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the job won't fail but an issue will be filed, correct?

That's the idea, yes.

Do we check those issues regularly?

I don't believe so, but could be a good addition to the weekly guild agenda. @emmettbutler @brettlangdon for your thoughts on this.

# latest `datadog` release on PyPI is ahead of the version pinned in
# ddtrace/vendor/__init__.py, so the team knows to evaluate a vendor bump.

on:
schedule:
# Every Monday at 09:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
check:
# Skip scheduled runs on forks to avoid unintended automation.
if: github.event_name != 'schedule' || github.event.repository.fork == false
name: Compare vendored vs PyPI version
runs-on: ubuntu-latest
Comment thread
vlad-scherbich marked this conversation as resolved.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"

Comment thread
vlad-scherbich marked this conversation as resolved.
- name: Check datadogpy vendor version
id: check
run: python .github/scripts/check_datadogpy_vendor.py
Comment thread
vlad-scherbich marked this conversation as resolved.

- name: Open or update tracking issue
if: steps.check.outputs.outdated == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const title = `chore(vendor): bump vendored datadogpy to ${process.env.LATEST_VERSION}`;
const body = [
`The \`datadog\` package on PyPI has a new release: **${process.env.LATEST_VERSION}**`,
`The version currently vendored in \`ddtrace/vendor/\` is **${process.env.VENDORED_VERSION}**.`,
``,
`cc @DataDog/python-guild`,
``,
`## Next steps`,
`1. Review the [datadogpy changelog](https://github.com/DataDog/datadogpy/blob/master/CHANGELOG.md) for changes across the \`datadog/dogstatsd/\` module (any file, not just \`container.py\`).`,
`2. Update the vendored copy of all changed files following the instructions in \`ddtrace/vendor/__init__.py\`.`,
`3. Re-apply any local patches documented in \`ddtrace/vendor/__init__.py\` under the \`dogstatsd\` section.`,
`4. Close this issue once the vendor bump lands.`,
``,
`_This issue was opened automatically by the [check-vendor-datadogpy](${{ github.server_url }}/${{ github.repository }}/actions/workflows/check-vendor-datadogpy.yml) workflow._`,
].join('\n');

// Check if a tracking issue already exists (avoid duplicates)
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'vendor-bump',
});

const alreadyOpen = existing.data.find(i => i.title.includes('datadogpy'));
if (alreadyOpen) {
console.log(`Issue #${alreadyOpen.number} already open — updating body`);
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: alreadyOpen.number,
title,
body,
});
} else {
const created = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['vendor-bump'],
});
console.log(`Opened issue #${created.data.number}`);
}
env:
LATEST_VERSION: ${{ steps.check.outputs.latest_version }}
VENDORED_VERSION: ${{ steps.check.outputs.vendored_version }}
Loading