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
41 changes: 33 additions & 8 deletions .github/scripts/check_installed_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
from zipfile import ZipFile

import numpy as np
from sklearn.metrics import accuracy_score, f1_score

import deeptab
from deeptab.configs import MLPConfig, PreprocessingConfig, TrainerConfig
from deeptab.metrics import Accuracy, F1Score
from deeptab.models import MLPClassifier, MLPRegressor


Expand All @@ -27,7 +29,7 @@ def main() -> None:
installed = distribution("deeptab")
if (
installed.version != deeptab.__version__
or Path(installed.locate_file("deeptab/__init__.py")).resolve() != package_path
or Path(str(installed.locate_file("deeptab/__init__.py"))).resolve() != package_path
):
raise RuntimeError("Imported DeepTab does not match the installed distribution metadata")
if args.wheel:
Expand All @@ -48,10 +50,15 @@ def main() -> None:
features = generator.normal(size=(48, 3)).astype(np.float32)

with TemporaryDirectory() as root:
for model_class in (MLPClassifier, MLPRegressor):
for model_class, case_name, class_labels in (
(MLPClassifier, "integer-labels", (10, 20, 30)),
(MLPClassifier, "binary-string-labels", ("no", "yes")),
(MLPClassifier, "multiclass-string-labels", ("blue", "green", "red")),
(MLPRegressor, "regression", None),
):
targets = (
np.tile([10, 20, 30], 16)
if model_class is MLPClassifier
np.resize(np.asarray(class_labels), 48)
if class_labels is not None
else features[:, 0] + 0.1 * generator.normal(size=48)
)
for width in (None, 15):
Expand All @@ -73,9 +80,9 @@ def main() -> None:
enable_progress_bar=False,
)
before = model.predict(features[36:])
if not np.isfinite(before).all():
if class_labels is None and not np.isfinite(before).all():
raise RuntimeError(f"{model_class.__name__} produced nonfinite predictions")
path = str(Path(root) / f"{model_class.__name__}-{width}.deeptab")
path = str(Path(root) / f"{model_class.__name__}-{case_name}-{width}.deeptab")
model.save(path)
restored = model_class.load(path, device="cpu")
np.testing.assert_array_equal(restored.predict(features[36:]), before)
Expand All @@ -86,9 +93,27 @@ def main() -> None:
probabilities = model.predict_proba(features[36:])
np.testing.assert_array_equal(restored.predict_proba(features[36:]), probabilities)
np.testing.assert_allclose(probabilities.sum(axis=1), 1, atol=1e-6)
if not set(before).issubset({10, 20, 30}):
if not set(before).issubset(class_labels or ()):
raise RuntimeError("Classifier did not restore the original target labels")
print(f"{model_class.__name__}, output_dim={width}: fit/save/CPU-reload passed")
metrics = {"accuracy": Accuracy(), "f1_macro": F1Score(average="macro")}
expected = {
"accuracy": accuracy_score(targets[36:], before),
"f1_macro": f1_score(targets[36:], before, average="macro"),
}
if class_labels is not None and len(class_labels) == 2:
metrics["f1_binary"] = F1Score(pos_label=class_labels[1])
expected["f1_binary"] = f1_score(
targets[36:], before, labels=[class_labels[1]], average="macro"
)
for estimator in (model, restored):
default_scores = estimator.evaluate(features[36:], targets[36:])
np.testing.assert_allclose(default_scores["accuracy"], expected["accuracy"])
np.testing.assert_allclose(estimator.score(features[36:], targets[36:]), expected["accuracy"])
scores = estimator.evaluate(features[36:], targets[36:], metrics=metrics)
for metric_name, metric in metrics.items():
np.testing.assert_allclose(metric(targets[36:], before), expected[metric_name])
np.testing.assert_allclose(scores[metric_name], expected[metric_name])
print(f"{model_class.__name__}, {case_name}, output_dim={width}: fit/evaluate/save/CPU-reload passed")


if __name__ == "__main__":
Expand Down
60 changes: 60 additions & 0 deletions .github/scripts/check_release_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Validate a release tag against its required remote branch history."""

import argparse
import os
import re
import shutil
import subprocess
import sys


def _git(*arguments: str) -> str:
executable = shutil.which("git")
if executable is None:
raise FileNotFoundError("Git is required for release validation.")
return subprocess.check_output([executable, *arguments], text=True).strip() # noqa: S603


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--channel", choices=("stable", "rc"), required=True)
args = parser.parse_args()
ref = os.environ.get("GITHUB_REF", "")
event_sha = os.environ.get("GITHUB_SHA", "")
tag = ref.removeprefix("refs/tags/")
release = re.fullmatch(
r"v((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))(rc[1-9][0-9]*)?",
tag,
)
if not ref.startswith("refs/tags/") or release is None or bool(release.group(2)) != (args.channel == "rc"):
print(f"Invalid {args.channel} release tag: {ref!r}.", file=sys.stderr)
return 1
if not event_sha:
print("Missing GITHUB_SHA for the release event.", file=sys.stderr)
return 1
if re.fullmatch(r"[0-9a-f]{40}", event_sha) is None:
print("Invalid GITHUB_SHA for the release event.", file=sys.stderr)
return 1

branch = "main" if args.channel == "stable" else f"release/v{release.group(1)}"
remote_ref = f"refs/remotes/origin/{branch}"
try:
tag_commit = _git("rev-parse", "--verify", f"{ref}^{{commit}}")
event_commit = _git("rev-parse", "--verify", f"{event_sha}^{{commit}}")
checkout_commit = _git("rev-parse", "HEAD")
if tag_commit != event_commit or checkout_commit != event_commit:
raise ValueError("Tag, checked-out commit, and event commit must match.")
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:{remote_ref}")
try:
_git("merge-base", "--is-ancestor", tag_commit, remote_ref)
except subprocess.CalledProcessError as error:
raise ValueError(f"Tag {tag} is not reachable from origin/{branch}.") from error
except (OSError, subprocess.CalledProcessError, ValueError) as error:
print(f"Release validation failed: {error}", file=sys.stderr)
return 1
print(f"Validated {tag}: {tag_commit} is reachable from origin/{branch}.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ on:
push:
branches:
- main
- "release/**"
pull_request:
branches:
- main
- "release/**"

concurrency:
group: ci-${{ github.workflow }}-${{ github.head_ref || github.sha }}
Expand Down Expand Up @@ -380,3 +382,35 @@ jobs:
with:
files: coverage.xml
fail_ci_if_error: false

ci-success:
name: CI Success
runs-on: ubuntu-latest
permissions: {}
if: ${{ always() }}
needs:
- lint
- typecheck
- build
- dependency-endpoints
- tests
- tests-extras
- tests-tabr
- smoke
- coverage

steps:
- name: Require all CI checks to succeed
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: |
python3 - <<'PY'
import json
import os

results = json.loads(os.environ["NEEDS_JSON"])
unsuccessful = {name: job.get("result") for name, job in results.items() if job.get("result") != "success"}
if not results or unsuccessful:
raise SystemExit(f"Required CI jobs did not all succeed: {unsuccessful or 'no results'}")
print("All required CI jobs succeeded.")
PY
22 changes: 20 additions & 2 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,41 @@ permissions:
contents: read

jobs:
provenance:
name: Validate release tag and ancestry
if: ${{ !contains(github.ref_name, 'rc') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate stable release provenance
run: python .github/scripts/check_release_ref.py --channel stable

ci:
if: ${{ !contains(github.ref_name, 'rc') }}
needs: package
needs: [provenance, package]
uses: ./.github/workflows/ci.yml
with:
distribution-artifact: dist

docs:
if: ${{ !contains(github.ref_name, 'rc') }}
needs: provenance
uses: ./.github/workflows/docs.yml

package:
if: ${{ !contains(github.ref_name, 'rc') }}
needs: provenance
uses: ./.github/workflows/build-check.yml

publish:
needs: [ci, docs, package]
needs: [provenance, ci, docs, package]
runs-on: ubuntu-latest
environment: pypi-publish
permissions:
Expand Down
21 changes: 19 additions & 2 deletions .github/workflows/publish-testpypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,37 @@ permissions:
contents: read

jobs:
provenance:
name: Validate release tag and ancestry
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate RC release provenance
run: python .github/scripts/check_release_ref.py --channel rc

ci:
needs: package
needs: [provenance, package]
uses: ./.github/workflows/ci.yml
with:
distribution-artifact: dist

docs:
needs: provenance
uses: ./.github/workflows/docs.yml

package:
needs: provenance
uses: ./.github/workflows/build-check.yml

publish-rc:
needs: [ci, docs, package]
needs: [provenance, ci, docs, package]
runs-on: ubuntu-latest
environment: testpypi-publish
permissions:
Expand Down
Loading
Loading