Skip to content

Commit

Permalink
Merge pull request #278 from opensafely-core/evansd/uv-compat
Browse files Browse the repository at this point in the history
Make it possible to install with `uv`
  • Loading branch information
evansd authored Oct 16, 2024
2 parents d9634cb + 831cb66 commit ed88355
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 24 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-20.04, windows-2019]
python-version: ["3.8", "3.9", "3.10"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
runs-on: ${{ matrix.os }}
name: Run test suite
steps:
Expand All @@ -17,6 +17,10 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install `uv`
uses: astral-sh/setup-uv@f3bcaebff5eace81a1c062af9f9011aae482ca9d # v3.1.7
with:
version: "0.4.20"
- name: Install dependencies
run: |
python -m pip install -r requirements.dev.txt
Expand Down
9 changes: 7 additions & 2 deletions opensafely/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,15 @@ def warn_if_updates_needed(argv):
try:
latest = upgrade.check_version()
if latest:
upgrade_command = (
"uv tool upgrade opensafely"
if upgrade.is_installed_with_uv()
else "opensafely upgrade"
)
print(
f"Warning: there is a newer version of opensafely available ({latest})"
" - please upgrade by running:\n"
" opensafely upgrade\n",
f" - please upgrade by running:\n"
f" {upgrade_command}\n",
file=sys.stderr,
)
# if we're out of date, don't warn the user about out of date images as well
Expand Down
18 changes: 18 additions & 0 deletions opensafely/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ def main(version):
print(f"opensafely is already at version {version}")
return 0

if is_installed_with_uv():
print(
"The OpenSAFELY tool has been installed using `uv` so cannot be directly"
" upgraded.\n"
"\n"
"Instead, please run:\n"
"\n"
" uv tool upgrade opensafely\n"
)
return 1

# Windows shennanigans: pip triggers a permissions error when it tries to
# update the currently executing binary. However if we replace the binary
# with a copy of itself (i.e. copy to a temporary file and then move the
Expand Down Expand Up @@ -84,3 +95,10 @@ def check_version():
return latest
else:
return False


def is_installed_with_uv():
# This was the most robust way I could think of for detecting a `uv` installation.
# I'm reasonably confident in its specificity. It's possible that a `uv` change will
# cause this to give false negatives, but the tests should catch that.
return Path(sys.prefix).joinpath("uv-receipt.toml").exists()
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
author="OpenSAFELY",
author_email="[email protected]",
python_requires=">=3.8",
install_requires=[
"setuptools",
],
entry_points={"console_scripts": ["opensafely=opensafely:main"]},
classifiers=["License :: OSI Approved :: GNU General Public License v3 (GPLv3)"],
project_urls={
Expand Down
99 changes: 78 additions & 21 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,44 @@

import pytest

import opensafely


BIN_DIR = "bin" if os.name != "nt" else "Scripts"

project_fixture_path = Path(__file__).parent / "fixtures" / "projects"


@pytest.mark.parametrize(
"package_type,ext", [("sdist", "tar.gz"), ("bdist_wheel", "whl")]
)
def test_packaging(package_type, ext, tmp_path):
project_root = Path(__file__).parent.parent
# This is pretty yucky. Ideally we'd stick all the build artefacts in a
# temporary directory but I can't seem to persuade setuptools to do this
shutil.rmtree(project_root / "dist", ignore_errors=True)
shutil.rmtree(project_root / "build", ignore_errors=True)
# Build the package
subprocess_run(
[sys.executable, "setup.py", package_type],
check=True,
cwd=project_root,
)
@pytest.fixture
def older_version_file():
# This is really not very nice, but short of reworking the way versioning is handled
# (which I don't want to do at the moment) I can't think of another way. In order to
# build a package with the right version (both in the metadata and in the code
# itself) we need to temporarily update the VERSION file.
version_file_path = Path(opensafely.__file__).parent / "VERSION"
orig_contents = version_file_path.read_bytes()
try:
version_file_path.write_text("0.1")
yield
finally:
version_file_path.write_bytes(orig_contents)


@pytest.mark.parametrize("package_type", ["sdist", "bdist_wheel"])
def test_packaging(package_type, tmp_path, older_version_file):
package_path = build_package(package_type)
# Install it in a temporary virtualenv
subprocess_run([sys.executable, "-m", "venv", tmp_path], check=True)
# sdist requires wheel to build
subprocess_run([tmp_path / BIN_DIR / "pip", "install", "wheel"], check=True)

package = list(project_root.glob(f"dist/*.{ext}"))[0]
subprocess_run([tmp_path / BIN_DIR / "pip", "install", package], check=True)
subprocess_run([tmp_path / BIN_DIR / "pip", "install", package_path], check=True)

# Smoketest it by running `--help` and `--version`. This is actually a more
# comprehensive test than you might think as it involves importing
# everything and because all the complexity in this project is in the
# vendoring and packaging, issues tend to show up at import time.
subprocess_run([tmp_path / BIN_DIR / "opensafely", "run", "--help"], check=True)
subprocess_run([tmp_path / BIN_DIR / "opensafely", "--version"], check=True)
# This always triggers an upgrade because the development version is always
# considered lower than any other version
subprocess_run([tmp_path / BIN_DIR / "opensafely", "upgrade", "1.7.0"], check=True)

# only on linux, as that has docker installed in GH
if sys.platform == "linux":
Expand All @@ -54,6 +54,63 @@ def test_packaging(package_type, ext, tmp_path):
cwd=str(project_fixture_path),
)

# This always triggers an upgrade because the development version is always
# considered lower than any other version
result = subprocess_run(
[tmp_path / BIN_DIR / "opensafely", "upgrade"],
check=True,
capture_output=True,
text=True,
)
assert "Attempting uninstall: opensafely" in result.stdout
assert "Successfully installed opensafely" in result.stdout


def test_installing_with_uv(tmp_path, older_version_file):
uv_bin = shutil.which("uv")
if uv_bin is None:
pytest.skip("Skipping as `uv` not installed")

package_path = build_package("bdist_wheel")
bin_path = tmp_path / "bin"
uv_env = dict(
os.environ,
UV_TOOL_BIN_DIR=bin_path,
UV_TOOL_DIR=tmp_path / "tools",
)
python_version = f"python{sys.version_info[0]}.{sys.version_info[1]}"
subprocess_run(
[uv_bin, "tool", "install", "--python", python_version, package_path],
env=uv_env,
check=True,
)
# Basic smoketest
subprocess_run([bin_path / "opensafely", "run", "--help"], check=True)
subprocess_run([bin_path / "opensafely", "--version"], check=True)
# The `upgrade` command should prompt the user to use `uv upgrade` instead
result = subprocess_run(
[bin_path / "opensafely", "upgrade"], capture_output=True, text=True
)
assert result.returncode != 0
assert "uv tool upgrade opensafely" in result.stdout


def build_package(package_type):
extension = {"sdist": "tar.gz", "bdist_wheel": "whl"}[package_type]
project_root = Path(__file__).parent.parent
# This is pretty yucky. Ideally we'd stick all the build artefacts in a
# temporary directory but I can't seem to persuade setuptools to do this
shutil.rmtree(project_root / "dist", ignore_errors=True)
shutil.rmtree(project_root / "build", ignore_errors=True)
# Build the package
subprocess_run(
[sys.executable, "setup.py", package_type],
check=True,
cwd=project_root,
)
package_path = list(project_root.glob(f"dist/*.{extension}"))[0]
return package_path


def subprocess_run(cmd_args, **kwargs):
"""
Expand Down

0 comments on commit ed88355

Please sign in to comment.