Skip to content

Commit

Permalink
CI: Run unit tests against installed wheels.
Browse files Browse the repository at this point in the history
Instead of running the unit tests against an “editable install” of
the PDR git repo, build an sdist and wheel from the git repo, then,
in separate jobs that don’t ever check out the git repo, install the
wheel and run the tests from the sdist against the installed wheel.

This has several advantages.  Most importantly, it verifies that we
generate *correct* binary packages, as we were not doing until the
previous commit.  Testing an editable install is also subtly different
in terms of things like sys.path than testing something that’s been
officially installed to site-packages.[^1]  Finally, this puts us in
a better position to create *conda* packages (as a separate project).

It would be possible to add further automation that uploads the sdist
and wheel to PyPI if the commit has a release tag.

The mechanism for tweaking .coveragerc for CI’s needs has also been
overhauled, using a helper Python script that parses .coveragerc
exactly the same way coverage.py itself does (i.e. using configparser).
This should be much more reliable, both because we no longer need to
make assumptions about what goes where in the file, and because the
Python script can also use the coverage.py API to compute the path-
remapping configuration (yes, coverage.py ought to do this itself,
filed nedbat/coveragepy#1840 ).  And it
means we can strip all the junk that’s in .coveragerc solely for
CI’s needs back out.

[^1]: https://setuptools.pypa.io/en/latest/userguide/development_mode.html#limitations
  • Loading branch information
zackw committed Aug 28, 2024
1 parent b11ef07 commit c508355
Show file tree
Hide file tree
Showing 4 changed files with 513 additions and 140 deletions.
13 changes: 1 addition & 12 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
# As of version 7.6.1 this is required to get "coverage combine" to do
# the Right Thing for data from both Unix and Windows.
[paths]
merge =
pdr/
pdr\

# This section needs to be last in this file because the CI scripts
# need to add things to it. The 'relative_files' and 'source' options
# are also necessary to get "coverage combine" to do the Right Thing.
[run]
relative_files = True
source = pdr
source_pkgs = pdr
omit =
*/formats/*
*/pvl_utils.py
Expand Down
112 changes: 112 additions & 0 deletions .github/scripts/adjust-coverage-config
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#! /usr/bin/env python3

"""
Read a .coveragerc from stdin, adjust it for use in a CI build, and
write it back out to stdout.
If files are listed on the command line, they are assumed to be
coverage databases, and a [paths] section is added to the .coveragerc
(replacing any existing [paths] section) that instructs coverage.py
to treat the common path prefix of each coverage database's files
as equivalent. When used this way, coverage.py must be importable.
"""

import sys

from argparse import ArgumentParser
from configparser import ConfigParser
from pathlib import Path


DATABASE_NAME = "coverage.dat"


def remap_paths_for_databases(cfg, databases):
from coverage import CoverageData
from collections import defaultdict
from os.path import commonprefix
from pathlib import PurePosixPath, PureWindowsPath

prefixes = set()
for db_fname in databases:
db = CoverageData(basename=db_fname)
db.read()
prefixes.add(commonprefix(list(db.measured_files())))

packages = defaultdict(set)
for p in prefixes:
if '\\' in p or (len(p) >= 2 and p[0].isalpha() and p[1] == ':'):
name = PureWindowsPath(p).name
else:
name = PurePosixPath(p).name
packages[name].add(p)

pkg_names = sorted(packages.keys())

cfg["run"]["relative_files"] = "true"
cfg["run"]["source_pkgs"] = " ".join(pkg_names)

cfg["paths"] = {}
for pkg in pkg_names:
pkg_paths = ['', pkg + '/']
pkg_paths.extend(sorted(packages[pkg]))
cfg["paths"]["src_" + pkg] = "\n".join(pkg_paths)


def adjust_omit(cfg):
"""
Adjust the "omit" setting to be more appropriate for use in CI;
the stock .coveragerc is tailored for interactive use.
"""
GLOBS_TO_DROP = (
"*/formats/*",
"*/pvl_utils.py",
)

run_section = cfg["run"]
pruned_omit_globs = []
for glob in run_section.get("omit", "").splitlines():
glob = glob.strip()
if glob not in GLOBS_TO_DROP:
pruned_omit_globs.append(glob)

if (
len(pruned_omit_globs) == 0
or len(pruned_omit_globs) == 1 and pruned_omit_globs[0] == ""
):
del run_section["omit"]
else:
run_section["omit"] = "\n".join(pruned_omit_globs)


def change_database_name(cfg):
"""
Give the coverage database a more convenient name for use in
cross-platform CI.
"""
cfg["run"]["data_file"] = str(Path.cwd() / DATABASE_NAME)


def main():
ap = ArgumentParser(description=__doc__)
ap.add_argument("databases", nargs="*",
help="Coverage databases to be merged")
args = ap.parse_args()

# this must match how coverage.py initializes ConfigParser
cfg = ConfigParser(interpolation=None)

with sys.stdin as ifp:
cfg.read_file(ifp, source="<stdin>")

if args.databases:
remap_paths_for_databases(cfg, args.databases)

adjust_omit(cfg)
change_database_name(cfg)

with sys.stdout as ofp:
cfg.write(ofp)


main()
90 changes: 90 additions & 0 deletions .github/scripts/find-gnu-tar
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#! /usr/bin/env python3

"""
Find GNU tar, whose pathname transformation options we need, and which
is named 'tar' on Github's Linux and Windows CI runners but 'gtar' on
their MacOS runners.
"""

import os
import stat
import sys

from argparse import ArgumentParser
from pathlib import Path


if os.name == "nt":
EXE_SUFFIX = ".exe"
def is_executable_mode(mode):
return True
else:
EXE_SUFFIX = ""
def is_executable_mode(mode):
return (stat.S_IMODE(mode) & 0o111) != 0


def is_executable_file(path, debug):
if debug:
sys.stderr.write(f" {path}: ")
try:
st = os.stat(path)
except FileNotFoundError:
if debug:
sys.stderr.write("not found\n")
return False

if not stat.S_ISREG(st.st_mode):
if debug:
sys.stderr.write("not a regular file (mode={})\n"
.format(stat.filemode(st.st_mode)))
return False

if not is_executable_mode(st.st_mode):
if debug:
sys.stderr.write("not executable (mode={}, os={})\n"
.format(stat.filemode(st.st_mode, os.name)))
return False

if debug:
sys.stderr.write(" ok\n")
return True



def find_gnu_tar(debug=False):
GTAR_CMD = "gtar" + EXE_SUFFIX
TAR_CMD = "tar" + EXE_SUFFIX
candidate = None
for d in os.get_exec_path():
# Resolve symlinks in the directory components of the path,
# but *not* the command name, because changing the command
# name might alter the behavior of the command.
p = Path(d).resolve()
if debug:
sys.stderr.write(f"checking {p}\n")
gtar = p / GTAR_CMD
tar = p / TAR_CMD
if is_executable_file(gtar, debug):
# gtar is preferred
return gtar
if is_executable_file(tar, debug):
# use tar only if we don't find a gtar later in the path
candidate = tar
if candidate is not None:
return candidate
sys.stderr.write(f"neither {GTAR_CMD} nor {TAR_CMD} found in PATH\n")
sys.exit(1)


def main():
ap = ArgumentParser(description=__doc__)
ap.add_argument("--debug", action="store_true",
help="Print debugging information during the search")
args = ap.parse_args()

sys.stdout.write(str(find_gnu_tar(args.debug)) + "\n")
sys.exit(0)


main()
Loading

0 comments on commit c508355

Please sign in to comment.