Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up GitHub Actions #1

Merged
merged 8 commits into from
Jan 29, 2024
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
19 changes: 19 additions & 0 deletions .github/codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
codecov:
require_ci_to_pass: true

coverage:
precision: 1
round: down
range: "70...100"
status:
project: # Coverage of whole project
default:
target: auto # Coverage target to pass; auto is base commit
threshold: 5% # Allow coverage to drop by this much vs. base and still pass
patch: # Coverage of lines in this change
default:
target: 80% # Coverage target to pass
threshold: 20% # Allow coverage to drop by this much vs. base and still pass

comment:
layout: "diff,flags,tree"
51 changes: 51 additions & 0 deletions .github/workflows/release-lib.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: release-lib

on:
push:
tags:
- "v*"

jobs:
build:
name: Publish library release
runs-on: "ubuntu-latest"

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install hatch
run: |
pip install hatch

- name: Check that versions match
id: version
run: |
echo "Release tag: [${{ github.ref_name }}]"
PACKAGE_VERSION=$(hatch version)
echo "Package version: [$PACKAGE_VERSION]"
[ "${{ github.ref_name }}" == "v$PACKAGE_VERSION" ] || { exit 1; }
echo "major_minor_version=v${PACKAGE_VERSION%.*}" >> $GITHUB_OUTPUT

- name: Build package
run: |
hatch build

- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@v1.8.11
with:
user: ${{ secrets.PYPI_TEST_USERNAME }}
password: ${{ secrets.PYPI_TEST_PASSWORD }}
repository-url: https://test.pypi.org/legacy/
skip-existing: true

- name: Publish to Production PyPI
uses: pypa/gh-action-pypi-publish@v1.8.11
with:
user: ${{ secrets.PYPI_PROD_USERNAME }}
password: ${{ secrets.PYPI_PROD_PASSWORD }}
skip-existing: false
100 changes: 100 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: tests

on:
push:
branches: [main]
pull_request:
schedule:
# Run every Sunday
- cron: "0 0 * * 0"
workflow_dispatch:

jobs:
code-quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: "pip"
cache-dependency-path: |
pyproject.toml

- name: Install hatch
run: |
pip install hatch

- name: Lint package
run: |
hatch run lint
hatch run typecheck

tests:
name: "Tests (${{ matrix.os }}, Python ${{ matrix.python-version }})"
needs: code-quality
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: |
pyproject.toml

- name: Install hatch
run: |
pip install hatch

- name: Run tests
run: |
hatch run tests.py${{ matrix.python-version }}:test

- name: Upload coverage to codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
fail_ci_if_error: true
if: ${{ matrix.os == 'ubuntu-latest' }}

- name: Build distribution and test installation
shell: bash
run: |
hatch build
if [[ ${{ matrix.os }} == "windows-latest" ]]; then
PYTHON_BIN=Scripts/python
else
PYTHON_BIN=bin/python
fi
echo "=== Testing wheel installation ==="
python -m venv .venv-whl
.venv-whl/$PYTHON_BIN -m pip install --upgrade pip
.venv-whl/$PYTHON_BIN -m pip install dist/repro_tarfile-*.whl
.venv-whl/$PYTHON_BIN -c "from repro_tarfile import ReproducibleTarFile"
echo "=== Testing source installation ==="
python -m venv .venv-sdist
.venv-sdist/$PYTHON_BIN -m pip install --upgrade pip
.venv-sdist/$PYTHON_BIN -m pip install dist/repro_tarfile-*.tar.gz --force-reinstall
.venv-sdist/$PYTHON_BIN -c "from repro_tarfile import ReproducibleTarFile"

notify:
name: Notify failed build
needs: [code-quality, tests]
if: failure() && github.event.pull_request == null
runs-on: ubuntu-latest
steps:
- uses: jayqi/failed-build-issue-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
51 changes: 33 additions & 18 deletions repro_tarfile.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextlib
import copy
import datetime
import os
import tarfile
Expand All @@ -7,15 +8,15 @@
__version__ = "0.1.0"


def date_time() -> float:
def date_time() -> int:
"""Returns date_time value used to force overwrite on all TarInfo objects. Defaults to
315550800.0 (corresponding to 1980-01-01 00:00:00 UTC). You can set this with the environment
variable SOURCE_DATE_EPOCH as an floating point number value representing seconds since Epoch.
315550800 (corresponding to 1980-01-01 00:00:00 UTC). You can set this with the environment
variable SOURCE_DATE_EPOCH as an integer value representing seconds since Epoch.
"""
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH", None)
if source_date_epoch is not None:
return float(source_date_epoch)
return datetime.datetime(1980, 1, 1, 0, 0, 0).timestamp()
return int(source_date_epoch)
return int(datetime.datetime(1980, 1, 1, 0, 0, 0).timestamp())


def file_mode() -> int:
Expand Down Expand Up @@ -104,13 +105,13 @@ def _temporarily_delete_tarfile_attr(tarinfo: tarfile.TarInfo):
yield
finally:
if tarfile_attr is not _NO_TARFILE_ATTR:
tarinfo.tarfile = tarfile_attr
# mypy doesn't handle seninel objects
# https://github.com/python/mypy/issues/15788
tarinfo.tarfile = tarfile_attr # type: ignore[assignment]


class ReproducibleTarFile(tarfile.TarFile):
def addfile(
self, tarinfo: tarfile.TarInfo, fileobj: Optional[IO[bytes]] = None
) -> None:
def addfile(self, tarinfo: tarfile.TarInfo, fileobj: Optional[IO[bytes]] = None) -> None:
"""Add the TarInfo object `tarinfo' to the archive. If `fileobj' is
given, it should be a binary file, and tarinfo.size bytes are read
from it and added to the archive. You can create TarInfo objects
Expand All @@ -122,15 +123,29 @@ def addfile(
mode = 0o100000 | file_mode()
# See docstring for _temporarily_delete_tarfile_attr for why we need to do this.
with _temporarily_delete_tarfile_attr(tarinfo):
tarinfo_copy = tarinfo.replace(
mtime=date_time(),
mode=mode,
uid=uid(),
gid=gid(),
uname=uname(),
gname=gname(),
deep=True,
)
try:
tarinfo_copy = tarinfo.replace(
mtime=date_time(),
mode=mode,
uid=uid(),
gid=gid(),
uname=uname(),
gname=gname(),
deep=True,
)
except AttributeError as e:
# Some older versions of Python don't have replace method
# Added in: 3.8.17, 3.9.17, 3.10.12, 3.11.4, 3.12
if "'TarInfo' object has no attribute 'replace'" in str(e):
tarinfo_copy = copy.deepcopy(tarinfo)
tarinfo_copy.mtime = date_time()
tarinfo_copy.mode = mode
tarinfo_copy.uid = uid()
tarinfo_copy.gid = gid()
tarinfo_copy.uname = uname()
tarinfo_copy.gname = gname()
else:
raise
return super().addfile(tarinfo=tarinfo_copy, fileobj=fileobj)


Expand Down
14 changes: 13 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from pathlib import Path
import sys

import pytest
from pytest_cases import fixture_union
Expand All @@ -21,4 +22,15 @@ def rel_path(tmp_path):
os.chdir(orig_wd)


base_path = fixture_union("base_path", ["rel_path", "abs_path"])
# Minimum versions with extractall filter support. Don't test abs_path if we don't have it.
EXTRACTALL_FILTER_MIN_VERSIONS = {
(3, 8): (3, 8, 17),
(3, 9): (3, 9, 17),
(3, 10): (3, 10, 12),
(3, 11): (3, 11, 4),
(3, 12): (3, 12),
}
if sys.version_info >= EXTRACTALL_FILTER_MIN_VERSIONS[sys.version_info[:2]]:
base_path = fixture_union("base_path", ["rel_path", "abs_path"])
else:
base_path = fixture_union("base_path", ["rel_path"])
9 changes: 5 additions & 4 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from io import StringIO
import platform
import sys
from time import sleep
from tarfile import TarFile, TarInfo
from time import sleep

from repro_tarfile import ReproducibleTarFile
from tests.utils import (
Expand Down Expand Up @@ -102,9 +103,9 @@ def test_add_dir_tree_mode(base_path):

# ReproducibleTarFile hashes should match; TarFile hashes should not
assert hash_file(rptf_arc1) == hash_file(rptf_arc2)
# if platform.system() != "Windows":
# # Windows doesn't seem to actually make them different
assert hash_file(tf_arc1) != hash_file(tf_arc2)
if platform.system() != "Windows":
# Windows doesn't seem to actually make them different
assert hash_file(tf_arc1) != hash_file(tf_arc2)


def test_add_dir_tree_string_paths(rel_path):
Expand Down
14 changes: 10 additions & 4 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,16 @@ def hash_file(path: Path):

def assert_archive_contents_equals(arc1: Path, arc2: Path):
with TemporaryDirectory() as outdir1, TemporaryDirectory() as outdir2:
with TarFile.open(arc1, "r") as tp:
tp.extractall(outdir1, filter="tar")
with TarFile.open(arc2, "r") as tp:
tp.extractall(outdir2, filter="tar")
try:
with TarFile.open(arc1, "r") as tp:
tp.extractall(outdir1, filter="tar")
with TarFile.open(arc2, "r") as tp:
tp.extractall(outdir2, filter="tar")
except TypeError:
with TarFile.open(arc1, "r") as tp:
tp.extractall(outdir1)
with TarFile.open(arc2, "r") as tp:
tp.extractall(outdir2)

extracted1 = sorted(Path(outdir1).glob("**/*"))
extracted2 = sorted(Path(outdir2).glob("**/*"))
Expand Down