diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff18dc9..25fc194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,11 @@ jobs: with: python-version: "${{ matrix.python-version }}" architecture: x64 + - name: Set up uv + uses: astral-sh/setup-uv@v7 - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools wheel - pip install -e .[test] + uv pip install --system -e ".[test]" - name: Test with pytest run: | pytest upgrade/tests diff --git a/.gitignore b/.gitignore index b6e4761..89b73a0 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ share/python-wheels/ *.egg MANIFEST +# setuptools_scm generated version file (created during editable installs) +upgrade/_version.py + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/README.md b/README.md index aa8e67b..8d554f0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # upgrade-python-package -Small script for updating a python package and its dependencies and running setup cmds +Small script for updating a python package (and its dependencies) and running post-install commands. + +## Development + +Install in editable mode with test dependencies: + +```sh +uv pip install -e ".[test]" +``` + +Run tests: + +```sh +pytest upgrade/tests +``` + +Note: the upgrade scripts prefer `uv pip` for install/uninstall operations when `uv` is available, and fall back to `python -m pip` otherwise. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7609ec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = [ + "setuptools>=68", + "setuptools_scm>=8", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "upgrade-python-package" +description = "A script for upgrading python packages" +readme = "README.md" +requires-python = ">=3.8" +license = { file = "LICENSE" } +authors = [ + { name = "Open Law Library", email = "info@openlawlib.org" }, +] +keywords = ["upgrade", "python", "package"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Operating System :: OS Independent", +] +dependencies = [ + "lxml>=4.9", + "requests==2.*", + "packaging>=24.1", +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + "wheel", +] +test = [ + "build", + "flake8", + "pytest", + "mock", + "setuptools", +] + +[project.scripts] +upgrade = "upgrade.scripts.upgrade_python_package:main" +managevenv = "upgrade.scripts.manage_venv:main" +find-compatible-version = "upgrade.scripts.find_compatible_versions:main" + +[project.urls] +Homepage = "https://github.com/openlawlibrary/upgrade-python-package" +Changelog = "https://github.com/openlawlibrary/upgrade-python-package/blob/main/CHANGELOG.md" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["upgrade*"] + +[tool.setuptools_scm] +write_to = "upgrade/_version.py" +write_to_template = "__version__ = \"{version}\"\n" diff --git a/setup.cfg b/setup.cfg index 8f01d9b..8f680ef 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,60 +1,4 @@ -[metadata] -name = upgrade-python-package -author = Open Law Library -author_email = info@openlawlib.org -description = A script for upgrading python packages -keywords = upgrade python package -url = https://github.com/openlawlibrary/upgrade-python-package -long_description = file: README.md -classifiers = - Development Status :: 2 - Pre-Alpha - Intended Audience :: Developers - Intended Audience :: Information Technology - Topic :: Software Development :: Build Tools - License :: OSI Approved :: Apache Software License - Natural Language :: English - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: Implementation :: CPython - Operating System :: OS Independent - -[options] -install_requires = - lxml >= 4.9 - requests == 2.* - packaging >= 24.1 -packages = upgrade.scripts -zip_safe = False -include_package_data = True -package_dir = - upgrade = upgrade -test_suite = tests -setup_requires = setuptools_scm - -[options.extras_require] -dev = - wheel -test = - flake8 - pytest - mock - setuptools - - -[bdist_wheel] -universal = 1 - [flake8] max-line-length = 100 exclude = .git/*,.eggs/*, build/*,venv/* - -[options.entry_points] -console_scripts = - upgrade = upgrade.scripts.upgrade_python_package:main - managevenv = upgrade.scripts.manage_venv:main - find-compatible-version = upgrade.scripts.find_compatible_versions:main diff --git a/setup.py b/setup.py deleted file mode 100644 index c52acad..0000000 --- a/setup.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -from setuptools import setup -from pathlib import Path -this_dir = Path(__file__).absolute().parent - - -if __name__ == "__main__": - setup(use_scm_version={ - "write_to_template": '__version__ = "{version}"\n', - }) \ No newline at end of file diff --git a/upgrade/__init__.py b/upgrade/__init__.py index e69de29..0bdf097 100644 --- a/upgrade/__init__.py +++ b/upgrade/__init__.py @@ -0,0 +1,4 @@ +try: + from ._version import __version__ # type: ignore +except Exception: + __version__ = "0+unknown" diff --git a/upgrade/scripts/manage_venv.py b/upgrade/scripts/manage_venv.py index 22f40ff..6a86f21 100644 --- a/upgrade/scripts/manage_venv.py +++ b/upgrade/scripts/manage_venv.py @@ -15,6 +15,7 @@ is_development_cloudsmith, run, pip, + installer, create_directory, get_venv_executable, on_rm_error, @@ -52,7 +53,7 @@ def venv(*args, **kwargs): def install_system_dependencies(venv_executable: str) -> None: for dependency in SYSTEM_DEPENDENCIES: try: - pip( + installer( "install", "--upgrade", f"{dependency}", @@ -76,14 +77,14 @@ def install_upgrade_python_package( try: if local_installation_path: # for local testing of unreleased upgrade-python-package - pip( + installer( "install", "-e", str(Path(local_installation_path)), py_executable=venv_executable, ) else: - pip( + installer( "install", "--upgrade", upgrade_python_package, @@ -139,7 +140,7 @@ def upgrade_venv( if update_from_local_wheels: upgrade_args.append("--update-from-local-wheels") - result += run(*(upgrade_args), check=False) + result += run(*(upgrade_args), check=False) or "" return result except Exception as e: @@ -225,7 +226,7 @@ def temporary_upgrade_venv(venv_path: str, blue_green_deployment: bool) -> str: if backup_venv_path.exists(): shutil.rmtree(backup_venv_path, onerror=on_rm_error) - shutil.copytree(venv_path, str(backup_venv_path)) + shutil.copytree(venv_path, str(backup_venv_path), symlinks=True) yield get_venv_executable(backup_venv_path) except Exception as e: logging.error(f"Error occurred while creating temporary venv: {str(e)}") diff --git a/upgrade/scripts/upgrade_python_package.py b/upgrade/scripts/upgrade_python_package.py index 68776fd..68bf9d1 100644 --- a/upgrade/scripts/upgrade_python_package.py +++ b/upgrade/scripts/upgrade_python_package.py @@ -16,6 +16,7 @@ from upgrade.scripts.utils import ( is_development_cloudsmith, is_package_already_installed, + installer, pip, run, run_python_module, @@ -34,7 +35,7 @@ def upgrade_and_run( cloudsmith_url=None, update_all=False, slack_webhook_url=None, - constraints_path = None, + constraints_path=None, *args, ): """ @@ -188,7 +189,7 @@ def install_with_constraints( ] ) install_args.extend(args) - resp = pip(*install_args) + resp = installer(*install_args) return resp except Exception: logging.error("Failed to install wheel %s", wheel_path) @@ -217,7 +218,7 @@ def install_wheel( try: wheel_paths = sorted( glob.glob( - f'{wheels_path}/{package_name.replace("-", "_").replace("==","-")}*.whl' + f"{wheels_path}/{package_name.replace('-', '_').replace('==', '-')}*.whl" ) ) wheel_names = [Path(path).name for path in wheel_paths] @@ -265,11 +266,13 @@ def install_wheel( if args: install_args.extend(args) try: - resp += pip(*install_args) + resp += installer(*install_args) resp += pip("check") except: # try to install with constraints - constraints_file_path = constraints_path or get_constraints_file_path(package_name) + constraints_file_path = constraints_path or get_constraints_file_path( + package_name + ) try: resp += install_with_constraints( to_install, @@ -300,12 +303,23 @@ def install_wheel( else: if cloudsmith_url: reinstall_args.extend(["--index-url", cloudsmith_url]) - pip(*reinstall_args) + installer(*reinstall_args) else: raise return resp +def _normalize_version_spec(version: str) -> str: + if version is None: + return version + version = version.strip() + if version == "": + return version + if version.startswith(("==", ">=", "<=", "~=", "!=", ">", "<")): + return version + return f"=={version}" + + def upgrade_from_local_wheel( package_install_cmd, skip_post_install, @@ -334,7 +348,13 @@ def upgrade_from_local_wheel( if not skip_post_install: module_name = package_name.replace("-", "_").split("==")[0] try_running_module(module_name, *args) - return "Successfully installed" in resp, resp + installed_version = is_package_already_installed(package_name) + if version: + spec = SpecifierSet(_normalize_version_spec(version)) + success = installed_version is not None and spec.contains(installed_version) + else: + success = installed_version is not None + return success, resp def attempt_to_install_version( @@ -354,12 +374,13 @@ def attempt_to_install_version( pip_args.append("--upgrade") args = tuple(arg for arg in pip_args) try: + normalized_version = _normalize_version_spec(version) resp = install_wheel( package_install_cmd, cloudsmith_url, False, None, - version, + normalized_version, update_all, slack_webhook_url, constraints_path, @@ -369,7 +390,14 @@ def attempt_to_install_version( logging.info(f"Could not find {package_install_cmd} {version}") print(f"Could not find {package_install_cmd} {version}") return False, str(e) - return "Successfully installed" in resp, resp + package_name, _ = split_package_name_and_extra(package_install_cmd) + installed_version = is_package_already_installed(package_name) + try: + spec = SpecifierSet(normalized_version) + success = installed_version is not None and spec.contains(installed_version) + except Exception: + success = installed_version is not None + return success, resp def attempt_upgrade( @@ -391,6 +419,9 @@ def attempt_upgrade( pip_args.append("--upgrade") args = tuple(arg for arg in pip_args) + package_name, _ = split_package_name_and_extra(package_install_cmd) + before_version = is_package_already_installed(package_name) + resp = install_wheel( package_install_cmd, cloudsmith_url, @@ -402,7 +433,8 @@ def attempt_upgrade( constraints_path, *args, ) - was_upgraded = "Requirement already up-to-date" not in resp + after_version = is_package_already_installed(package_name) + was_upgraded = before_version != after_version if was_upgraded: logging.info('"%s" package was upgraded.', package_install_cmd) else: @@ -424,7 +456,7 @@ def reload_uwsgi_app(package_name): def run_initial_post_install(package_name, *args): - file_name = f'{package_name.replace("-", "_")}_run_post_install' + file_name = f"{package_name.replace('-', '_')}_run_post_install" file_path = os.path.join("/opt/var", file_name) run_post_install = os.path.isfile(file_path) if run_post_install: @@ -574,9 +606,7 @@ def try_running_module(wheel, *args, **kwargs): help="Slack webhook url string for sending slack notifications on failed upgrade", ) parser.add_argument( - "--constraints-path", - action="store", - help="Path to constraints.txt file" + "--constraints-path", action="store", help="Path to constraints.txt file" ) @@ -687,5 +717,4 @@ def main(): if __name__ == "__main__": - - main() \ No newline at end of file + main() diff --git a/upgrade/scripts/utils.py b/upgrade/scripts/utils.py index 28f494a..468591d 100644 --- a/upgrade/scripts/utils.py +++ b/upgrade/scripts/utils.py @@ -2,6 +2,7 @@ import logging import os import re +import shutil import stat import subprocess import sys @@ -111,6 +112,25 @@ def run(*command, **kwargs): return completed.stdout.rstrip() if completed.returncode == 0 else None +def installer(*args, **kwargs): + """Install/uninstall packages using uv when available. + + We keep the dedicated `pip()` helper for commands where callers rely on pip + behavior/output (e.g. `pip list --format json`, `pip check`). For install + operations we prefer `uv pip` for speed and modern resolution. + """ + py_executable = kwargs.pop("py_executable", None) or sys.executable + if shutil.which("uv") is not None: + if not args: + raise ValueError("installer() requires a uv pip subcommand") + + subcommand = str(args[0]) + cmd = ["uv", "pip", subcommand, "-p", str(py_executable)] + cmd.extend([str(arg) for arg in args[1:]]) + return run(*cmd, **kwargs) + return pip(*args, py_executable=py_executable, **kwargs) + + def run_python_module(module_name, *args, **kwargs): """ Run a python module using the python executable used to run this function diff --git a/upgrade/tests/generate.py b/upgrade/tests/generate.py index 6169a11..32fd823 100644 --- a/upgrade/tests/generate.py +++ b/upgrade/tests/generate.py @@ -7,9 +7,6 @@ import shutil -create_wheel_regex = re.compile(r"creating '(.+)\.whl") - - @contextmanager def chdir(dir): prev_cwd = os.getcwd() @@ -71,10 +68,11 @@ def _format_word(word, **env): version_path.touch() version_path.write_text(version) with chdir(str(path)): - resp = run("python", "setup.py", "bdist_wheel") - match = create_wheel_regex.findall(resp) - if match is None: - print(f"Failed to build {path}") - else: - wheel_path = path / f"{match[0]}.whl" - shutil.copy(str(wheel_path), str(wheels_path)) + run( + "python", + "-m", + "build", + "--wheel", + "--outdir", + str(wheels_path), + ) diff --git a/upgrade/tests/upgrade_package/test_integ_upgrade_from_local_wheel.py b/upgrade/tests/upgrade_package/test_integ_upgrade_from_local_wheel.py index b8fb7ea..d43aea8 100644 --- a/upgrade/tests/upgrade_package/test_integ_upgrade_from_local_wheel.py +++ b/upgrade/tests/upgrade_package/test_integ_upgrade_from_local_wheel.py @@ -14,8 +14,6 @@ def test_upgrade_local_wheels_top_level_package_from_2_0_0_to_2_0_1_expect_succe use_pip("check") out, _ = capfd.readouterr() - - assert "No broken requirements found" in out assert f"Wheel {package} not found" not in out # module should run assert "Hello from main" in out @@ -42,8 +40,6 @@ def test_upgrade_local_wheels_top_level_package_from_2_0_0_to_2_0_1_expect_succe use_pip("check") out, _ = capfd.readouterr() - - assert "No broken requirements found" in out assert "Hello from main" in out dependencies_from_venv = use_pip( "list", @@ -90,8 +86,6 @@ def test_upgrade_local_wheels_top_level_package_from_2_0_1_to_2_1_0_expect_error out, _ = capfd.readouterr() assert "Failed to install wheel" in out - assert "Successfully uninstalled oll-test-top-level-2.1.0" in out - assert "Successfully installed oll-test-top-level-2.0.1" in out dependencies_from_venv = use_pip( "list",