Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
6a37083
In test_cext: Tell setuptools when we are generating a limited_api mo…
stefanor Jul 1, 2026
15cd434
Make debug builds configurable in reusable-ubuntu
stefanor Jul 1, 2026
db6e1a9
Tidy up YAML
stefanor Jul 1, 2026
216fae0
Make tests optional in reusable-ubuntu
stefanor Jul 1, 2026
f20a5b0
Allow reusable-ubuntu to install and upload results
stefanor Jul 1, 2026
f059478
Matrix to run installs on Ubuntu
stefanor Jul 1, 2026
2f5cd99
Compare multiple install results
stefanor Jul 1, 2026
e6dd647
Skip comparing abi3 and abi3t for now
stefanor Jul 1, 2026
b84e719
NEWS entry
stefanor Jul 1, 2026
3fce580
Upload hashes rather than installs
stefanor Jul 2, 2026
66d9cf8
strict type Tools/coinstall-check/
stefanor Jul 3, 2026
5443e16
Use Path.read_bytes()
stefanor Jul 3, 2026
ace1e1f
Factor out dirname patch checks
stefanor Jul 3, 2026
f12b47a
Restructure GitHub CI workflow, from review
stefanor Jul 3, 2026
f17d761
Remove some other unnecessary if: ${{}} wrapping
stefanor Jul 3, 2026
8062995
Upload a JSON manifest so that we can do ignores during comparison
stefanor Jul 3, 2026
7ef137f
Build the co-install manifests in the normal test runs
stefanor Jul 3, 2026
d4c6100
Update test_build_details to work with --with-build-details-suffix
stefanor Jul 3, 2026
d390e7a
Strip absolute --libdir paths from configure args in test_freeze
stefanor Oct 2, 2024
5be0ae7
Properly check in all-required-green
stefanor Jul 3, 2026
3eb814b
Drop reusable-ubuntu:inputs.test, no longer needed
stefanor Jul 3, 2026
356df50
Use the commit timestamp for timestamps the install
stefanor Jul 9, 2026
d758215
More readable template
stefanor Jul 9, 2026
058c925
Ensure that we are comparing a useful set of Python builds
stefanor Jul 9, 2026
621a078
Merge main
stefanor Jul 12, 2026
e3ff3e5
Merge branch 'main' into ci-multiarch-co-install
encukou Jul 29, 2026
e0735a4
.abi3 should no longer conflict
stefanor Jul 28, 2026
20b87b6
re-black
stefanor Jul 29, 2026
44531f9
Rename function for clarity
stefanor Jul 29, 2026
dec7615
Set SOURCE_DATE_EPOCH later, it casuses some test failures
stefanor Jul 29, 2026
21b6432
Comment for delayed SOURCE_DATE_EPOCH
stefanor Jul 30, 2026
7d503e8
Tidier case
stefanor Jul 30, 2026
32f21fe
We don't need inputs.debug
stefanor Jul 30, 2026
46d5263
Anonymous refernce
stefanor Jul 30, 2026
e0567f9
Tidy imports
stefanor Jul 30, 2026
981cbdf
Unused variable
stefanor Jul 30, 2026
02e180e
Require -o
stefanor Jul 30, 2026
fb9ec69
Can't benefit from CODEOWNERS without being a core-dev
stefanor Jul 30, 2026
0bce099
Improve file description comment
stefanor Jul 30, 2026
abe3f10
Drop shebangs, not used
stefanor Jul 30, 2026
727b533
Report the names of both sides of a mismatch
stefanor Jul 30, 2026
273088b
Drop unused definition
stefanor Jul 30, 2026
1968b91
Drop executable bit, not used
stefanor Jul 30, 2026
9770ca4
Don't set SOURCE_DATE_EPOCH at all
stefanor Jul 30, 2026
eba5adc
More colorful output
stefanor Jul 31, 2026
09cdce6
Move linux-install-compare into its own reusable workflow
stefanor Jul 31, 2026
5dc25db
Merge main
stefanor Jul 31, 2026
c8fbe8f
Restore blank line
stefanor Jul 31, 2026
9af8639
Provide the on property
stefanor Jul 31, 2026
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
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ jobs:
free-threading: ${{ matrix.free-threading }}
os: ${{ matrix.os }}
test-opts: ${{ matrix.test-opts || '' }}
upload-install-hashes: ${{ !matrix.bolt }}

build-ubuntu-ssltests:
name: 'Ubuntu SSL tests'
Expand Down Expand Up @@ -579,6 +580,11 @@ jobs:
run: |
"$BUILD_DIR/cross-python/bin/python3" -m test test_sysconfig test_site test_embed

linux-install-compare:
Comment thread
stefanor marked this conversation as resolved.
name: Ubuntu co-install comparison
uses: ./.github/workflows/reusable-linux-install-compare.yml
needs: build-ubuntu

cifuzz:
# ${{ '' } is a hack to nest jobs under the same sidebar category.
name: CIFuzz${{ '' }} # zizmor: ignore[obfuscation]
Expand Down Expand Up @@ -645,6 +651,7 @@ jobs:
- build-san
- cross-build-linux
- cifuzz
- linux-install-compare
if: always()

steps:
Expand Down Expand Up @@ -690,6 +697,7 @@ jobs:
build-asan,
build-san,
cross-build-linux,
linux-install-compare,
'
|| ''
}}
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/reusable-linux-install-compare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Reusable Ubuntu co-install comparison

on:
workflow_call: {}

permissions:
contents: read

env:
FORCE_COLOR: 1

jobs:
install-compare:
name: Ubuntu co-install comparison
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.x'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download install hashes
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: install-hashes
pattern: install-hashes-*
merge-multiple: true
- name: Compare install hashes
run: python3 Tools/coinstall-check/compare.py install-hashes
51 changes: 51 additions & 0 deletions .github/workflows/reusable-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ on:
required: false
type: string
default: ''
upload-install-hashes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks like this input is always passed exactly opposite to bolt-optimizations. A case could be made that it could be computed instead...

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.

They may currently be opposite, but I think they are orthogonal, I wouldn't want to compute one from the other.

upload-install-hashes should be set once for each (architecture, build flags) combination.

I could imagine adding a check that aborts a build if upload-install-hashes is enable and any other non-architcture, non-build-flag option is non-default. Would that improve things?

description: Install Python and upload the result artifact
required: false
type: boolean
default: false

permissions:
contents: read
Expand All @@ -35,6 +40,12 @@ jobs:
runs-on: ${{ inputs.os }}
timeout-minutes: 60
env:
Comment thread
stefanor marked this conversation as resolved.
INSTALL_HASHES_FILE: >-
install-hashes-${{
inputs.os
}}-${{
case(inputs.free-threading, 't', '')
}}d.json.gz
OPENSSL_VER: 3.5.7
PYTHONSTRICTEXTENSIONBUILD: 1
TERM: linux
Expand Down Expand Up @@ -90,6 +101,7 @@ jobs:
--with-openssl="$OPENSSL_DIR"
${{ inputs.free-threading && '--disable-gil' || '' }}
${{ inputs.bolt-optimizations && '--enable-bolt' || '' }}
${{ inputs.upload-install-hashes && '--prefix=/usr --libdir=/usr/lib/$(gcc --print-multiarch) --with-build-details-suffix' || '' }}
- name: Build CPython out-of-tree
if: inputs.free-threading
working-directory: ${{ env.CPYTHON_BUILDDIR }}
Expand Down Expand Up @@ -119,3 +131,42 @@ jobs:
run: xvfb-run make ci EXTRATESTOPTS="${TEST_OPTS}"
env:
TEST_OPTS: ${{ inputs.test-opts }}
- name: Install Python
if: inputs.upload-install-hashes
working-directory: ${{ env.CPYTHON_BUILDDIR }}
run: make install DESTDIR=install
- name: Install test C extension
if: inputs.upload-install-hashes
working-directory: ${{ env.CPYTHON_BUILDDIR }}
env:
CPYTHON_TEST_EXT_NAME: c_mod
run: install/usr/bin/python3 -m pip install ../cpython-ro-srcdir/Lib/test/test_cext
- name: Install test stable ABI extension
if: inputs.upload-install-hashes && !inputs.free-threading
working-directory: ${{ env.CPYTHON_BUILDDIR }}
env:
CPYTHON_TEST_EXT_NAME: abi3_mod
CPYTHON_TEST_LIMITED: 1
run: install/usr/bin/python3 -m pip install ../cpython-ro-srcdir/Lib/test/test_cext
- name: Install test free-threaded stable ABI extension
if: inputs.upload-install-hashes
working-directory: ${{ env.CPYTHON_BUILDDIR }}
env:
CPYTHON_TEST_EXT_NAME: abi3t_mod
CPYTHON_TEST_ABI3T: 1
run: install/usr/bin/python3 -m pip install ../cpython-ro-srcdir/Lib/test/test_cext
- name: Hash the installed Python
if: inputs.upload-install-hashes
working-directory: ${{ env.CPYTHON_BUILDDIR }}
run: >-
install/usr/bin/python3
../cpython-ro-srcdir/Tools/coinstall-check/hash-r.py
install -o "$INSTALL_HASHES_FILE"
- name: Upload the installed Python hashes
if: inputs.upload-install-hashes
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ env.INSTALL_HASHES_FILE }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this also expose a wildcard for the consumers to look up the artifacts? Though, this probably won't work with a matrix 🤔

path: ${{ env.CPYTHON_BUILDDIR }}/${{ env.INSTALL_HASHES_FILE }}
archive: false
retention-days: 1
3 changes: 2 additions & 1 deletion Lib/test/test_build_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ def location(self):
dirname = os.path.join(projectdir, f.read())
else:
dirname = sysconfig.get_path('stdlib')
return os.path.join(dirname, 'build-details.json')
filename = sysconfig.get_config_var('BUILD_DETAILS')
return os.path.join(dirname, filename)

@property
def contents(self):
Expand Down
6 changes: 5 additions & 1 deletion Lib/test/test_cext/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ def main():
if internal:
cflags.append('-DTEST_INTERNAL_C_API=1')

py_limited_api = limited or abi3t

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.

This looks unrelated to the purpose of this PR, but is a correctness fix, so 👍🏼

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.

It was necessary to tell setuptools to produce abi3/abi3t filenames.


# Add additional include and library directories, typically for in-tree
# testing where not all directories are inferred
include_dirs = []
Expand All @@ -131,7 +133,9 @@ def main():
sources=sources,
extra_compile_args=cflags,
include_dirs=include_dirs,
library_dirs=library_dirs)
library_dirs=library_dirs,
py_limited_api=py_limited_api,
)
setup(name=f'internal_{module_name}',
version='0.0',
ext_modules=[ext])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CI Tests to ensure Debian `multi-arch co-installability
<https://www.debian.org/doc/debian-policy/ch-controlfields.html#multi-arch>`__
of Python.
3 changes: 3 additions & 0 deletions Tools/README
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ clinic A preprocessor for CPython C files in order to automate
the boilerplate involved with writing argument parsing
code for "builtins".

coinstall-check A tool to ensure that multiple CPython builds can be
co-installed on Linux.

freeze Create a stand-alone executable from a Python program.

ftscalingbench Benchmarks for free-threading and finding bottlenecks.
Expand Down
128 changes: 128 additions & 0 deletions Tools/coinstall-check/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Compare that multiple installs of Python don't have conflicting files.
#
# This is a requirement for Debian's Multi-Arch installs of Python
# https://www.debian.org/doc/debian-policy/ch-controlfields.html#multi-arch

from argparse import ArgumentParser
from pathlib import Path
from typing import Any
import _colorize
import gzip
import json


def compare_install_manifests(base: Path) -> bool:
"""Compare all json manifests inside the directory at base."""
hashes_seen: dict[str, tuple[str, str]] = {}
tags_seen_by_platform: dict[str, set[frozenset[str]]] = {}
colors = _colorize.get_colors()

success: bool = True
for tree in base.iterdir():
if not tree.is_file():
continue

print(f"Examining {tree}")
with gzip.open(tree) as f:
data = json.load(f)
build_details = data["build_details"]
hashes = data["hashes"]
tags_seen_by_platform.setdefault(build_details["platform"], set()).add(
frozenset(build_details["abi"]["flags"])
)

for path, digest in hashes.items():
if is_ignored(path, build_details):
continue
if path not in hashes_seen:
hashes_seen[path] = (digest, tree.name)
continue
expected, source_name = hashes_seen[path]
if digest != expected:
print(f"{colors.RED}Mismatch found{colors.RESET}: {path}")
print(f"{digest} ({tree.name}) != {expected} ({source_name})")
success = False
Comment thread
stefanor marked this conversation as resolved.

# Did we see enough builds to make a useful comparison?
if len(tags_seen_by_platform) < 2:
print(
f"{colors.RED}ERROR{colors.RESET}: Insufficient platforms "
"(architectures) to compare. Expected >= 2."
)
success = False

for tagsets in tags_seen_by_platform.values():
if len(tagsets) >= 2:
break
else:
print(
f"{colors.RED}ERROR{colors.RESET}: Insufficient configuration "
f"variants tested. Expected >= 2."
)
success = False

return success


def is_ignored(pathname: str, build_details: dict[str, Any]) -> bool:
"""Is this a path that we should ignore?"""

path = Path(pathname)

if path.parent.name == "__pycache__":
# Includes a timestamp, we expect a mismatch
return True

if path.is_relative_to("usr/bin"):
# Only libraries are multi-arch co-installed, only one arch can
# have binaries in /usr/bin at a time.
return True

in_usr_include = path.is_relative_to("usr/include")
if in_usr_include and path.name == "pyconfig.h":
# Varies according to config, installed into a tag-specific
# include directory
return True

in_usr_lib = path.is_relative_to("usr/lib")
in_pkgconfig = in_usr_lib and path.parent.name == "pkgconfig"
if in_pkgconfig and path.name in ("python3.pc", "python3-embed.pc"):
# Only the tag-suffixed .pc files are co-installable
return True

version = build_details["language"]["version"]
if (
in_pkgconfig
and build_details["abi"]["flags"] # non-default install
and path.name in (f"python-{version}.pc", f"python-{version}-embed.pc")
):
# Only the tag-suffixed .pc files are co-installable
return True

in_dist_info = path.parent.name.endswith(".dist-info")
if in_dist_info and path.name in ("RECORD", "WHEEL"):
# RECORD: Contains hashes, not co-installable.
# WHEEL: Contains arch and version tags. Tags can be merged but
# not architectures.
return True

return False


def main() -> None:
Comment thread
stefanor marked this conversation as resolved.
p = ArgumentParser("Compare multiple hash-r files")
p.add_argument(
"base_directory",
type=Path,
help="Directory containing hashes of Python installs.",
)
args = p.parse_args()
if not compare_install_manifests(args.base_directory):
raise SystemExit(1)

Comment thread
stefanor marked this conversation as resolved.
colors = _colorize.get_colors()
print(f"{args.base_directory} {colors.GREEN}OK{colors.RESET}")


if __name__ == "__main__":
main()
62 changes: 62 additions & 0 deletions Tools/coinstall-check/hash-r.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Export a SHA-512 manifest of installed files, so that we can ensure that
# multiple installs of Python don't have conflicting files.
#
# This is a requirement for Debian's Multi-Arch installs of Python
# https://www.debian.org/doc/debian-policy/ch-controlfields.html#multi-arch

import gzip
import json
from argparse import ArgumentParser
from hashlib import file_digest
from pathlib import Path
from typing import Any, cast


def load_build_details(base: Path) -> dict[str, Any]:
for path in base.glob("usr/lib/python*/build-details*.json"):
details = json.loads(path.read_bytes())
return cast(dict[str, Any], details)
raise AssertionError(f"build-details.json not found in {base}")


def hash_tree(base: Path, algorithm: str = "sha512") -> dict[str, str]:
hashes: dict[str, str] = {}
for dirpath, dirnames, filenames in base.walk():
for file in filenames:
filepath = dirpath / file
with filepath.open("rb") as f:
digest = file_digest(f, algorithm)
hashes[str(filepath.relative_to(base))] = digest.hexdigest()
return hashes


def write_json(destdir: Path, output: Path) -> None:
"""Hash the Python install at destdir, write gzipped JSON to output."""
data = {
"build_details": load_build_details(destdir),
"hashes": hash_tree(destdir),
}
with gzip.open(output, "wt") as f:
f.write(json.dumps(data))


def main() -> None:
p = ArgumentParser("Hash a Python install for comparison later")
p.add_argument(
"destdir",
type=Path,
help="Directory below which Python is installed",
)
Comment thread
stefanor marked this conversation as resolved.
p.add_argument(
"-o",
"--output",
type=Path,
help="Output file (gzipped)",
required=True,
)
args = p.parse_args()
write_json(args.destdir, args.output)


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions Tools/freeze/test/freeze.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def prepare(script=None, outdir=None):
# Run configure.
print(f'configuring python in {builddir}...')
config_args = shlex.split(sysconfig.get_config_var('CONFIG_ARGS') or '')
config_args = [arg for arg in config_args if not arg.startswith("--libdir=/")]
cmd = [os.path.join(srcdir, 'configure'), *config_args]
ensure_opt(cmd, 'cache-file', os.path.join(outdir, 'python-config.cache'))
prefix = os.path.join(outdir, 'python-installation')
Expand Down
Loading