diff --git a/.github/workflows/python-static-analysis-and-test.yml b/.github/workflows/python-static-analysis-and-test.yml index 512681c..b8ee105 100644 --- a/.github/workflows/python-static-analysis-and-test.yml +++ b/.github/workflows/python-static-analysis-and-test.yml @@ -52,15 +52,6 @@ jobs: json_ver: ['json', 'json5'] os: ['ubuntu-latest', 'windows-latest'] python: ['3.7', '3.8', '3.9', '3.10', '3.11'] - # Works around the depreciation of python 3.6 for ubuntu - # https://github.com/actions/setup-python/issues/544 - include: - - json_ver: 'json' - os: 'ubuntu-20.04' - python: '3.6' - - json_ver: 'json5' - os: 'ubuntu-20.04' - python: '3.6' runs-on: ${{ matrix.os }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbc0b9d..68e2c47 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: rev: v2.5.0 hooks: - id: setup-cfg-fmt - args: [--min-py3-version=3.6] + args: [--min-py3-version=3.7] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 diff --git a/README.md b/README.md index d47acbb..2ba91ac 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ home directory on other platforms. ## Installing -Hab is installed using pip. It requires python 3.6 or above. It's recommended +Hab is installed using pip. It requires python 3.7 or above. It's recommended that you add the path to your python's bin or Scripts folder to the `PATH` environment variable so you can simply run the `hab` command. diff --git a/hab/launcher.py b/hab/launcher.py index 87c889b..4a665a3 100644 --- a/hab/launcher.py +++ b/hab/launcher.py @@ -3,13 +3,6 @@ from . import utils -try: - CREATE_NO_WINDOW = subprocess.CREATE_NO_WINDOW -except AttributeError: - # This constant comes from the WindowsAPI, but is not - # defined in subprocess until python 3.7 - CREATE_NO_WINDOW = 0x08000000 - class Launcher(subprocess.Popen): """Runs cmd using subprocess.Popen enabling stdout/err/in redirection. @@ -42,6 +35,6 @@ def __init__(self, args, **kwargs): # If this is a pythonw process, because there is no current window # for stdout, any subprocesses will try to create a new window - kwargs.setdefault("creationflags", CREATE_NO_WINDOW) + kwargs.setdefault("creationflags", subprocess.CREATE_NO_WINDOW) super().__init__(args, **kwargs) diff --git a/hab/user_prefs.py b/hab/user_prefs.py index 3780088..9c7b5d2 100644 --- a/hab/user_prefs.py +++ b/hab/user_prefs.py @@ -131,16 +131,10 @@ def save(self): @classmethod def _fromisoformat(cls, value): - """Calls `datetime.fromisoforamt` if possible otherwise replicates - its basic requirements (for python 3.6 support). - """ + """Calls `datetime.fromisoformat` unless a datetime is passed.""" if isinstance(value, datetime.datetime): return value - try: - return datetime.datetime.fromisoformat(value) - except AttributeError: - iso_format = r"%Y-%m-%dT%H:%M:%S.%f" - return datetime.datetime.strptime(value, iso_format) + return datetime.datetime.fromisoformat(value) def uri_check(self): """Returns the uri saved in preferences. It will only do that if enabled diff --git a/setup.cfg b/setup.cfg index bdd8693..2392826 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,7 @@ install_requires = importlib-metadata packaging>=20.0 setuptools-scm[toml]>=4 -python_requires = >=3.6 +python_requires = >=3.7 include_package_data = True scripts = bin/.hab-complete.bash diff --git a/tests/test_launch.py b/tests/test_launch.py index fda8429..b030d88 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -1,7 +1,5 @@ -import functools import os import re -import site import subprocess import sys @@ -15,40 +13,6 @@ class Topen(subprocess.Popen): """A custom subclass of Popen.""" -def missing_annotations_hack(function): - """Decorator that works around a missing annotations module in python 3.6. - - Allows for calling subprocess calls in python 3.6 that would normally fail - with a `SyntaxError: future feature annotations is not defined` exception. - - Works by temporarily removing the `_virtualenv.pth` file in the venv's - site-packages. - - TODO: Figure out a better method until we can drop CentOS requirement. - """ - - @functools.wraps(function) - def new_function(*args, **kwargs): - if sys.version_info.minor != 6: - return function(*args, **kwargs) - - site_packages = site.getsitepackages() - for path in site_packages: - pth = os.path.join(path, "_virtualenv.pth") - if os.path.exists(pth): - os.rename(pth, f"{pth}.bak") - try: - ret = function(*args, **kwargs) - finally: - for path in site_packages: - pth = os.path.join(path, "_virtualenv.pth.bak") - if os.path.exists(pth): - os.rename(pth, pth[:-3]) - return ret - - return new_function - - def test_launch(resolver): """Check the Config.launch method.""" cfg = resolver.resolve("app/aliased/mod") @@ -72,7 +36,6 @@ def test_launch(resolver): assert "\n".join(check) in proc.output_stdout -@missing_annotations_hack def test_launch_str(resolver): cfg = resolver.resolve("app/aliased/mod") @@ -212,14 +175,11 @@ class TestCliExitCodes: # but is used to ensure that exit-codes are returned to the calling process. py_cmd = "print('Running...');import sys;print(sys);sys.exit({code})" output_text = "Running...\n\n" - run_kwargs = dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5) - if sys.version_info.minor >= 7: - run_kwargs["text"] = True - else: - run_kwargs["universal_newlines"] = True + run_kwargs = dict( + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5, text=True + ) @pytest.mark.skipif(sys.platform != "win32", reason="only applies on windows") - @missing_annotations_hack def test_bat(self, config_root, exit_code, tmp_path): hab_bin = (config_root / ".." / "bin" / "hab.bat").resolve() # fmt: off @@ -247,7 +207,6 @@ def test_bat(self, config_root, exit_code, tmp_path): os.getenv("GITHUB_ACTIONS") == "true", reason="PowerShell tests timeout when run in a github action", ) - @missing_annotations_hack def test_ps1(self, config_root, exit_code): # -File is needed to get the exit-code from powershell, and requires a full path script = (config_root / ".." / "bin" / "hab.ps1").resolve() diff --git a/tests/test_lazy_distro_version.py b/tests/test_lazy_distro_version.py new file mode 100644 index 0000000..0745591 --- /dev/null +++ b/tests/test_lazy_distro_version.py @@ -0,0 +1,151 @@ +import pytest +from packaging.requirements import Requirement +from packaging.version import Version + +from hab import DistroMode +from hab.errors import InstallDestinationExistsError +from hab.parsers.lazy_distro_version import DistroPath, LazyDistroVersion + + +def test_distro_path(zip_distro_sidecar, helpers, tmp_path): + resolver = helpers.render_resolver( + "site_distro_zip_sidecar.json", + tmp_path, + zip_root=zip_distro_sidecar.root.as_posix(), + ) + with resolver.distro_mode_override(DistroMode.Downloaded): + distro = resolver.find_distro("dist_a==0.2") + + # Passing root as a string converts it to a pathlib.Path object. + dpath = DistroPath( + distro, str(tmp_path), relative="{distro_name}-v{version}", site=resolver.site + ) + # Test that the custom relative string, it used to generate root + assert dpath.root == tmp_path / "dist_a-v0.2" + assert dpath.hab_filename == tmp_path / "dist_a-v0.2" / ".hab.json" + + # If site and relative are not passed the default is used + dpath = DistroPath(distro, tmp_path) + assert dpath.root == tmp_path / "dist_a" / "0.2" + assert dpath.hab_filename == tmp_path / "dist_a" / "0.2" / ".hab.json" + + # Test that site settings are respected when not passing relative + resolver.site.downloads["relative_path"] = "parent/{distro_name}/child/{version}" + dpath = DistroPath(distro, tmp_path, site=resolver.site) + assert dpath.root == tmp_path / "parent" / "dist_a" / "child" / "0.2" + assert ( + dpath.hab_filename + == tmp_path / "parent" / "dist_a" / "child" / "0.2" / ".hab.json" + ) + + +def test_is_lazy(zip_distro_sidecar, helpers, tmp_path): + """Check that a LazyDistroVersion doesn't automatically load all data.""" + resolver = helpers.render_resolver( + "site_distro_zip_sidecar.json", + tmp_path, + zip_root=zip_distro_sidecar.root.as_posix(), + ) + with resolver.distro_mode_override(DistroMode.Downloaded): + distro = resolver.find_distro("dist_a==0.1") + + frozen_data = dict( + context=["dist_a"], + name="dist_a==0.1", + version=Version("0.1"), + ) + filename = zip_distro_sidecar.root / "dist_a_v0.1.hab.json" + + # The find_distro call should have called load but does not actually load data + assert isinstance(distro, LazyDistroVersion) + assert distro._loaded is False + assert distro.context == ["dist_a"] + assert distro.filename == filename + assert distro.frozen_data == frozen_data + assert distro.name == "dist_a==0.1" + + # Calling _ensure_loaded actually loads the full distro from the finder's data + data = distro._ensure_loaded() + assert distro._loaded is True + assert isinstance(data, dict) + assert distro.name == "dist_a==0.1" + + # If called a second time, then nothing extra is done and no data is returned. + assert distro._ensure_loaded() is None + + +def test_bad_kwargs(): + """Test that the proper error is raised if you attempt to init with a filename.""" + match = "Passing filename to this class is not supported." + with pytest.raises(ValueError, match=match): + LazyDistroVersion(None, None, "filename") + + with pytest.raises(ValueError, match=match): + LazyDistroVersion(None, None, filename="a/filename") + + +@pytest.mark.parametrize( + "prop,check", + (("distros", {"dist_b": Requirement("dist_b")}),), +) +def test_lazy_hab_property(prop, check, zip_distro_sidecar, helpers, tmp_path): + """Check that a LazyDistroVersion doesn't automatically load all data.""" + resolver = helpers.render_resolver( + "site_distro_zip_sidecar.json", + tmp_path, + zip_root=zip_distro_sidecar.root.as_posix(), + ) + with resolver.distro_mode_override(DistroMode.Downloaded): + distro = resolver.find_distro("dist_a==0.2") + + # Calling a lazy getter ensures the data is loaded + assert distro._loaded is False + value = getattr(distro, prop) + assert distro._loaded is True + assert value == check + + # You can call the lazy getter repeatedly + value = getattr(distro, prop) + assert value == check + + +def test_install(zip_distro_sidecar, helpers, tmp_path): + """Check that a LazyDistroVersion doesn't automatically load all data.""" + resolver = helpers.render_resolver( + "site_distro_zip_sidecar.json", + tmp_path, + zip_root=zip_distro_sidecar.root.as_posix(), + ) + with resolver.distro_mode_override(DistroMode.Downloaded): + distro = resolver.find_distro("dist_a==0.2") + dest_root = resolver.site.downloads["install_root"] + distro_root = dest_root / "dist_a" / "0.2" + hab_json = distro_root / ".hab.json" + + # The distro is not currently installed. This also tests that it can + # auto-cast to DistroPath + assert not distro.installed(dest_root) + + # Install will clear the cache, ensure its populated + assert resolver._downloadable_distros is not None + # Install the distro using LazyDistroVersion + distro.install(dest_root) + assert distro.installed(dest_root) + assert hab_json.exists() + # Check that the cache was cleared by the install function + assert resolver._downloadable_distros is None + + # Test that if the distro is already installed, an error is raised + with pytest.raises(InstallDestinationExistsError) as excinfo: + distro.install(dest_root) + assert excinfo.value.filename == distro_root + + # Test forced replacement of an existing distro by creating an extra file + extra_file = distro_root / "extra_file.txt" + extra_file.touch() + # This won't raise the exception, but will remove the old distro + distro.install(dest_root, replace=True) + assert hab_json.exists() + assert distro.installed(dest_root) + + assert not extra_file.exists() diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 18bd1b4..afd2b8b 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,6 +1,5 @@ import os import pathlib -import sys from collections import OrderedDict from pathlib import Path from zipfile import ZipFile @@ -624,13 +623,7 @@ def test_forced_requirements( # Ensure this is a deepcopy of forced and ensure the values are equal assert resolver_forced.__forced_requirements__ is not forced for k, v in resolver_forced.__forced_requirements__.items(): - if sys.version_info.minor == 6: - # NOTE: packaging>22.0 doesn't support equal checks for Requirement - # objects. Python 3.6 only has a 21 release, so we have to compare str - # TODO: Once we drop py3.6 support drop this if statement - assert str(forced[k]) == str(v) - else: - assert forced[k] == v + assert forced[k] == v assert forced[k] is not v # Check that forced_requirements work if the config defines zero distros diff --git a/tox.ini b/tox.ini index 2d086c7..a738dd3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = begin,py{36,37,38,39,310,311}-{json,json5},end,black,flake8 +envlist = begin,py{37,38,39,310,311}-{json,json5},end,black,flake8 skip_missing_interpreters = True skipsdist = True @@ -29,14 +29,14 @@ commands = coverage erase -[testenv:py{36,37,38,39,310,311}-{json,json5}] +[testenv:py{37,38,39,310,311}-{json,json5}] depends = begin [testenv:end] basepython = python3 depends = begin - py{36,37,38,39,310,311}-{json,json5} + py{37,38,39,310,311}-{json,json5} parallel_show_output = True deps = coverage