diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 627189b9..922de82a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,6 +59,11 @@ jobs: --file tests/requirements.txt \ python=${{ matrix.python-version }} + - name: Patch conda with PR#11882 (TEMPORARY) + shell: bash -el {0} + run: | + pip install -U --no-deps https://github.com/conda/conda/archive/cep-menuinst.tar.gz + - shell: bash -el {0} name: Conda info run: | @@ -119,7 +124,7 @@ jobs: # Explicitly use Python 3.11 since each of the OSes has a different default Python - uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: "3.11" - name: Detect label shell: python @@ -153,4 +158,4 @@ jobs: anaconda-org-channel: conda-canary anaconda-org-label: ${{ env.ANACONDA_ORG_LABEL }} anaconda-org-token: ${{ secrets.ANACONDA_ORG_CONDA_CANARY_TOKEN }} - conda-build-arguments: '--override-channels -c conda-forge -c defaults' + conda-build-arguments: "--override-channels -c conda-forge -c defaults" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea6cfdb7..eb673d74 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,3 +33,4 @@ repos: rev: 6.1.0 hooks: - id: flake8 +exclude: ^menuinst/_vendor/.*$ diff --git a/MANIFEST.in b/MANIFEST.in index a0c1025e..b83b7dab 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,4 +2,5 @@ include menuinst/_version.py include menuinst/data/*.json include menuinst/data/osx_launcher_* include menuinst/data/appkit_launcher_* +include menuinst/_vendor/apipkg/LICENSE exclude *.h *.cpp diff --git a/dev/vendoring/vendor.txt b/dev/vendoring/vendor.txt new file mode 100644 index 00000000..5f3692b3 --- /dev/null +++ b/dev/vendoring/vendor.txt @@ -0,0 +1 @@ +apipkg==3.0.1 diff --git a/menuinst/__init__.py b/menuinst/__init__.py index 039986c4..b2af68ae 100644 --- a/menuinst/__init__.py +++ b/menuinst/__init__.py @@ -1,59 +1,47 @@ """ """ -import json import os -import sys -from logging import basicConfig as _basicConfig -from logging import getLogger as _getLogger -from os import PathLike +from logging import basicConfig, getLogger + +from .api import _install_adapter as install try: from ._version import __version__ except ImportError: __version__ = "dev" - -from ._legacy import install as _legacy_install -from .api import install as _api_install -from .api import remove as _api_remove -from .utils import DEFAULT_BASE_PREFIX, DEFAULT_PREFIX - -_log = _getLogger(__name__) - - +log = getLogger(__name__) if os.environ.get("MENUINST_DEBUG"): - _basicConfig(level="DEBUG") - - -def install(path: PathLike, remove: bool = False, prefix: PathLike = DEFAULT_PREFIX, **kwargs): - """ - This function is only here as a legacy adapter for menuinst v1.x. - Please use `menuinst.api` functions instead. - """ - if sys.platform == "win32": - path = path.replace("/", "\\") - json_path = os.path.join(prefix, path) - with open(json_path) as f: - metadata = json.load(f) - if "$id" not in metadata: # old style JSON - if sys.platform == "win32": - kwargs.setdefault("root_prefix", kwargs.pop("base_prefix", DEFAULT_BASE_PREFIX)) - if kwargs["root_prefix"] is None: - kwargs["root_prefix"] = DEFAULT_BASE_PREFIX - _legacy_install(json_path, remove=remove, prefix=prefix, **kwargs) - else: - _log.warn( - "menuinst._legacy is only supported on Windows. " - "Switch to the new-style menu definitions " - "for cross-platform compatibility." - ) - else: - # patch kwargs to reroute root_prefix to base_prefix - kwargs.setdefault("base_prefix", kwargs.pop("root_prefix", DEFAULT_BASE_PREFIX)) - if kwargs["base_prefix"] is None: - kwargs["base_prefix"] = DEFAULT_BASE_PREFIX - if remove: - _api_remove(metadata, target_prefix=prefix, **kwargs) - else: - _api_install(metadata, target_prefix=prefix, **kwargs) + basicConfig(level="DEBUG") + +__all__ = ["install", "__version__"] + + +# Compatibility forwarders for menuinst v1.x (Windows only) +if os.name == "nt": + from ._vendor.apipkg import initpkg + + initpkg( + __name__, + exportdefs={ + "win32": { + "dirs_src": "menuinst.platforms.win_utils.knownfolders:dirs_src", + }, + "knownfolders": { + "get_folder_path": "menuinst.platforms.win_utils.knownfolders:get_folder_path", + "FOLDERID": "menuinst.platforms.win_utils.knownfolders:FOLDERID", + }, + "winshortcut": { + "create_shortcut": "menuinst.platforms.win_utils.winshortcut:create_shortcut", + }, + "win_elevate": { + "runAsAdmin": "menuinst.platforms.win_utils.win_elevate:runAsAdmin", + "isUserAdmin": "menuinst.platforms.win_utils.win_elevate:isUserAdmin", + }, + }, + # Calling initpkg WILL CLEAR the 'menuinst' top-level namespace, and only then will add + # the exportdefs contents! If we want to keep something defined in this module, we MUST + # make sure it's added in the 'attr' dictionary below. + attr={"__version__": __version__, "install": install}, + ) diff --git a/menuinst/_vendor/apipkg/LICENSE b/menuinst/_vendor/apipkg/LICENSE new file mode 100644 index 00000000..ff33b8f7 --- /dev/null +++ b/menuinst/_vendor/apipkg/LICENSE @@ -0,0 +1,18 @@ + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/menuinst/_vendor/apipkg/__init__.py b/menuinst/_vendor/apipkg/__init__.py new file mode 100644 index 00000000..efd65767 --- /dev/null +++ b/menuinst/_vendor/apipkg/__init__.py @@ -0,0 +1,39 @@ +""" +apipkg: control the exported namespace of a Python package. + +see https://pypi.python.org/pypi/apipkg + +(c) holger krekel, 2009 - MIT license +""" +from __future__ import annotations + +__all__ = ["initpkg", "ApiModule", "AliasModule", "__version__", "distribution_version"] +import sys +from typing import Any + +from ._alias_module import AliasModule +from ._importing import distribution_version as distribution_version +from ._module import _initpkg +from ._module import ApiModule +from ._version import version as __version__ + + +def initpkg( + pkgname: str, + exportdefs: dict[str, Any], + attr: dict[str, object] | None = None, + eager: bool = False, +) -> ApiModule: + """initialize given package from the export definitions.""" + attr = attr or {} + mod = sys.modules.get(pkgname) + + mod = _initpkg(mod, pkgname, exportdefs, attr=attr) + + # eagerload in bypthon to avoid their monkeypatching breaking packages + if "bpython" in sys.modules or eager: + for module in list(sys.modules.values()): + if isinstance(module, ApiModule): + getattr(module, "__dict__") + + return mod diff --git a/menuinst/_vendor/apipkg/_alias_module.py b/menuinst/_vendor/apipkg/_alias_module.py new file mode 100644 index 00000000..a6f219ae --- /dev/null +++ b/menuinst/_vendor/apipkg/_alias_module.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from types import ModuleType + +from ._importing import importobj + + +def AliasModule(modname: str, modpath: str, attrname: str | None = None) -> ModuleType: + + cached_obj: object | None = None + + def getmod() -> object: + nonlocal cached_obj + if cached_obj is None: + cached_obj = importobj(modpath, attrname) + return cached_obj + + x = modpath + ("." + attrname if attrname else "") + repr_result = f"" + + class AliasModule(ModuleType): + def __repr__(self) -> str: + return repr_result + + def __getattribute__(self, name: str) -> object: + try: + return getattr(getmod(), name) + except ImportError: + if modpath == "pytest" and attrname is None: + # hack for pylibs py.test + return None + else: + raise + + def __setattr__(self, name: str, value: object) -> None: + setattr(getmod(), name, value) + + def __delattr__(self, name: str) -> None: + delattr(getmod(), name) + + return AliasModule(str(modname)) diff --git a/menuinst/_vendor/apipkg/_importing.py b/menuinst/_vendor/apipkg/_importing.py new file mode 100644 index 00000000..ea458705 --- /dev/null +++ b/menuinst/_vendor/apipkg/_importing.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import os +import sys + + +def _py_abspath(path: str) -> str: + """ + special version of abspath + that will leave paths from jython jars alone + """ + if path.startswith("__pyclasspath__"): + return path + else: + return os.path.abspath(path) + + +def distribution_version(name: str) -> str | None: + """try to get the version of the named distribution, + returns None on failure""" + if sys.version_info >= (3, 8): + from importlib.metadata import PackageNotFoundError, version + else: + from importlib_metadata import PackageNotFoundError, version + try: + return version(name) + except PackageNotFoundError: + return None + + +def importobj(modpath: str, attrname: str | None) -> object: + """imports a module, then resolves the attrname on it""" + module = __import__(modpath, None, None, ["__doc__"]) + if not attrname: + return module + + retval = module + names = attrname.split(".") + for x in names: + retval = getattr(retval, x) + return retval diff --git a/menuinst/_vendor/apipkg/_module.py b/menuinst/_vendor/apipkg/_module.py new file mode 100644 index 00000000..5088034b --- /dev/null +++ b/menuinst/_vendor/apipkg/_module.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import sys +import threading +from types import ModuleType +from typing import Any +from typing import Callable +from typing import cast +from typing import Iterable + +from ._importing import _py_abspath +from ._importing import importobj +from ._syncronized import _synchronized +from menuinst._vendor.apipkg import AliasModule + + +class ApiModule(ModuleType): + """the magical lazy-loading module standing""" + + def __docget(self) -> str | None: + try: + return self.__doc + except AttributeError: + if "__doc__" in self.__map__: + return cast(str, self.__makeattr("__doc__")) + else: + return None + + def __docset(self, value: str) -> None: + self.__doc = value + + __doc__ = property(__docget, __docset) # type: ignore + __map__: dict[str, tuple[str, str]] + + def __init__( + self, + name: str, + importspec: dict[str, Any], + implprefix: str | None = None, + attr: dict[str, Any] | None = None, + ) -> None: + super().__init__(name) + self.__name__ = name + self.__all__ = [x for x in importspec if x != "__onfirstaccess__"] + self.__map__ = {} + self.__implprefix__ = implprefix or name + if attr: + for name, val in attr.items(): + setattr(self, name, val) + for name, importspec in importspec.items(): + if isinstance(importspec, dict): + subname = f"{self.__name__}.{name}" + apimod = ApiModule(subname, importspec, implprefix) + sys.modules[subname] = apimod + setattr(self, name, apimod) + else: + parts = importspec.split(":") + modpath = parts.pop(0) + attrname = parts and parts[0] or "" + if modpath[0] == ".": + modpath = implprefix + modpath + + if not attrname: + subname = f"{self.__name__}.{name}" + apimod = AliasModule(subname, modpath) + sys.modules[subname] = apimod + if "." not in name: + setattr(self, name, apimod) + else: + self.__map__[name] = (modpath, attrname) + + def __repr__(self): + repr_list = [f"") + return "".join(repr_list) + + @_synchronized + def __makeattr(self, name, isgetattr=False): + """lazily compute value for name or raise AttributeError if unknown.""" + target = None + if "__onfirstaccess__" in self.__map__: + target = self.__map__.pop("__onfirstaccess__") + fn = cast(Callable[[], None], importobj(*target)) + fn() + try: + modpath, attrname = self.__map__[name] + except KeyError: + # __getattr__ is called when the attribute does not exist, but it may have + # been set by the onfirstaccess call above. Infinite recursion is not + # possible as __onfirstaccess__ is removed before the call (unless the call + # adds __onfirstaccess__ to __map__ explicitly, which is not our problem) + if target is not None and name != "__onfirstaccess__": + return getattr(self, name) + # Attribute may also have been set during a concurrent call to __getattr__ + # which executed after this call was already waiting on the lock. Check + # for a recently set attribute while avoiding infinite recursion: + # * Don't call __getattribute__ if __makeattr was called from a data + # descriptor such as the __doc__ or __dict__ properties, since data + # descriptors are called as part of object.__getattribute__ + # * Only call __getattribute__ if there is a possibility something has set + # the attribute we're looking for since __getattr__ was called + if threading is not None and isgetattr: + return super().__getattribute__(name) + raise AttributeError(name) + else: + result = importobj(modpath, attrname) + setattr(self, name, result) + # in a recursive-import situation a double-del can happen + self.__map__.pop(name, None) + return result + + def __getattr__(self, name): + return self.__makeattr(name, isgetattr=True) + + def __dir__(self) -> Iterable[str]: + yield from super().__dir__() + yield from self.__map__ + + @property + def __dict__(self) -> dict[str, Any]: # type: ignore + # force all the content of the module + # to be loaded when __dict__ is read + dictdescr = ModuleType.__dict__["__dict__"] # type: ignore + ns: dict[str, Any] = dictdescr.__get__(self) + if ns is not None: + hasattr(self, "some") + for name in self.__all__: + try: + self.__makeattr(name) + except AttributeError: + pass + return ns + + +_PRESERVED_MODULE_ATTRS = { + "__file__", + "__version__", + "__loader__", + "__path__", + "__package__", + "__doc__", + "__spec__", + "__dict__", +} + + +def _initpkg(mod: ModuleType | None, pkgname, exportdefs, attr=None) -> ApiModule: + """Helper for initpkg. + + Python 3.3+ uses finer grained locking for imports, and checks sys.modules before + acquiring the lock to avoid the overhead of the fine-grained locking. This + introduces a race condition when a module is imported by multiple threads + concurrently - some threads will see the initial module and some the replacement + ApiModule. We avoid this by updating the existing module in-place. + + """ + if mod is None: + d = {"__file__": None, "__spec__": None} + d.update(attr) + mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d) + sys.modules[pkgname] = mod + return mod + else: + f = getattr(mod, "__file__", None) + if f: + f = _py_abspath(f) + mod.__file__ = f + if hasattr(mod, "__path__"): + mod.__path__ = [_py_abspath(p) for p in mod.__path__] + if "__doc__" in exportdefs and hasattr(mod, "__doc__"): + del mod.__doc__ + for name in dir(mod): + if name not in _PRESERVED_MODULE_ATTRS: + delattr(mod, name) + + # Updating class of existing module as per importlib.util.LazyLoader + mod.__class__ = ApiModule + apimod = cast(ApiModule, mod) + ApiModule.__init__(apimod, pkgname, exportdefs, implprefix=pkgname, attr=attr) + return apimod diff --git a/menuinst/_vendor/apipkg/_syncronized.py b/menuinst/_vendor/apipkg/_syncronized.py new file mode 100644 index 00000000..71d3c2cb --- /dev/null +++ b/menuinst/_vendor/apipkg/_syncronized.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import functools +import threading + + +def _synchronized(wrapped_function): + """Decorator to synchronise __getattr__ calls.""" + + # Lock shared between all instances of ApiModule to avoid possible deadlocks + lock = threading.RLock() + + @functools.wraps(wrapped_function) + def synchronized_wrapper_function(*args, **kwargs): + with lock: + return wrapped_function(*args, **kwargs) + + return synchronized_wrapper_function diff --git a/menuinst/_vendor/apipkg/_version.py b/menuinst/_vendor/apipkg/_version.py new file mode 100644 index 00000000..773afd39 --- /dev/null +++ b/menuinst/_vendor/apipkg/_version.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# file generated by setuptools_scm +# don't change, don't track in version control +version = '3.0.1' +version_tuple = (3, 0, 1) diff --git a/menuinst/_vendor/apipkg/py.typed b/menuinst/_vendor/apipkg/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/menuinst/api.py b/menuinst/api.py index c8527101..05045be9 100644 --- a/menuinst/api.py +++ b/menuinst/api.py @@ -2,10 +2,10 @@ """ import json +import os import sys import warnings from logging import getLogger -from os import PathLike from pathlib import Path from typing import Any, Callable, List, Optional, Tuple, Union @@ -24,9 +24,9 @@ def _load( - metadata_or_path: Union[PathLike, dict], - target_prefix: Optional[PathLike] = None, - base_prefix: Optional[PathLike] = None, + metadata_or_path: Union[os.PathLike, dict], + target_prefix: Optional[os.PathLike] = None, + base_prefix: Optional[os.PathLike] = None, _mode: _UserOrSystem = "user", ) -> Tuple[Menu, List[MenuItem]]: target_prefix = target_prefix or DEFAULT_PREFIX @@ -43,12 +43,12 @@ def _load( @elevate_as_needed def install( - metadata_or_path: Union[PathLike, dict], + metadata_or_path: Union[os.PathLike, dict], *, - target_prefix: Optional[PathLike] = None, - base_prefix: Optional[PathLike] = None, + target_prefix: Optional[os.PathLike] = None, + base_prefix: Optional[os.PathLike] = None, _mode: _UserOrSystem = "user", -) -> List[PathLike]: +) -> List[os.PathLike]: target_prefix = target_prefix or DEFAULT_PREFIX base_prefix = base_prefix or DEFAULT_BASE_PREFIX menu, menu_items = _load(metadata_or_path, target_prefix, base_prefix, _mode) @@ -66,12 +66,12 @@ def install( @elevate_as_needed def remove( - metadata_or_path: Union[PathLike, dict], + metadata_or_path: Union[os.PathLike, dict], *, - target_prefix: Optional[PathLike] = None, - base_prefix: Optional[PathLike] = None, + target_prefix: Optional[os.PathLike] = None, + base_prefix: Optional[os.PathLike] = None, _mode: _UserOrSystem = "user", -) -> List[PathLike]: +) -> List[os.PathLike]: target_prefix = target_prefix or DEFAULT_PREFIX base_prefix = base_prefix or DEFAULT_BASE_PREFIX menu, menu_items = _load(metadata_or_path, target_prefix, base_prefix, _mode) @@ -90,11 +90,11 @@ def remove( @elevate_as_needed def install_all( *, - target_prefix: Optional[PathLike] = None, - base_prefix: Optional[PathLike] = None, + target_prefix: Optional[os.PathLike] = None, + base_prefix: Optional[os.PathLike] = None, filter: Union[Callable, None] = None, _mode: _UserOrSystem = "user", -) -> List[List[PathLike]]: +) -> List[List[os.PathLike]]: target_prefix = target_prefix or DEFAULT_PREFIX base_prefix = base_prefix or DEFAULT_BASE_PREFIX return _process_all(install, target_prefix, base_prefix, filter, _mode) @@ -103,11 +103,11 @@ def install_all( @elevate_as_needed def remove_all( *, - target_prefix: Optional[PathLike] = None, - base_prefix: Optional[PathLike] = None, + target_prefix: Optional[os.PathLike] = None, + base_prefix: Optional[os.PathLike] = None, filter: Union[Callable, None] = None, _mode: _UserOrSystem = "user", -) -> List[List[Union[str, PathLike]]]: +) -> List[List[Union[str, os.PathLike]]]: target_prefix = target_prefix or DEFAULT_PREFIX base_prefix = base_prefix or DEFAULT_BASE_PREFIX return _process_all(remove, target_prefix, base_prefix, filter, _mode) @@ -115,8 +115,8 @@ def remove_all( def _process_all( function: Callable, - target_prefix: Optional[PathLike] = None, - base_prefix: Optional[PathLike] = None, + target_prefix: Optional[os.PathLike] = None, + base_prefix: Optional[os.PathLike] = None, filter: Union[Callable, None] = None, _mode: _UserOrSystem = "user", ) -> List[Any]: @@ -128,3 +128,43 @@ def _process_all( if filter is not None and filter(path): results.append(function(path, target_prefix, base_prefix, _mode)) return results + + +_api_remove = remove # alias to prevent shadowing in the function below + + +def _install_adapter( + path: os.PathLike, remove: bool = False, prefix: os.PathLike = DEFAULT_PREFIX, **kwargs +): + """ + This function is only here as a legacy adapter for menuinst v1.x. + Please use `menuinst.api` functions instead. + """ + if os.name == "nt": + path = path.replace("/", "\\") + json_path = os.path.join(prefix, path) + with open(json_path) as f: + metadata = json.load(f) + if "$id" not in metadata: # old style JSON + from ._legacy import install as _legacy_install + + if os.name == "nt": + kwargs.setdefault("root_prefix", kwargs.pop("base_prefix", DEFAULT_BASE_PREFIX)) + if kwargs["root_prefix"] is None: + kwargs["root_prefix"] = DEFAULT_BASE_PREFIX + _legacy_install(json_path, remove=remove, prefix=prefix, **kwargs) + else: + log.warn( + "menuinst._legacy is only supported on Windows. " + "Switch to the new-style menu definitions " + "for cross-platform compatibility." + ) + else: + # patch kwargs to reroute root_prefix to base_prefix + kwargs.setdefault("base_prefix", kwargs.pop("root_prefix", DEFAULT_BASE_PREFIX)) + if kwargs["base_prefix"] is None: + kwargs["base_prefix"] = DEFAULT_BASE_PREFIX + if remove: + _api_remove(metadata, target_prefix=prefix, **kwargs) + else: + install(metadata, target_prefix=prefix, **kwargs) diff --git a/news/151-v1-compat b/news/151-v1-compat new file mode 100644 index 00000000..b7f132b6 --- /dev/null +++ b/news/151-v1-compat @@ -0,0 +1,19 @@ +### Enhancements + +* + +### Bug fixes + +* Ensure some v1-only import paths are still available for backwards compatibility. (#151) + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/pyproject.toml b/pyproject.toml index 59fd22d2..b8ae330e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,18 @@ line-length = 99 exclude = [ "_schema.py" ] + +[tool.vendoring] +destination = "menuinst/_vendor/" +namespace = "menuinst._vendor" +requirements = "dev/vendoring/vendor.txt" + +patches-dir = "dev/vendoring/patches" +protected-files = ["__init__.py", "vendor.txt", "README.md"] + +[tool.vendoring.typing-stubs] +# prevent stubs from being generated +apipkg = [] + +[tool.vendoring.license.fallback-urls] +apipkg = "https://raw.githubusercontent.com/pytest-dev/apipkg/master/LICENSE" diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 537d92e3..d36db425 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -52,8 +52,10 @@ test: about: home: https://github.com/conda/menuinst/ - license: BSD-3-Clause - license_file: LICENSE.txt + license: BSD-3-Clause AND MIT + license_file: + - LICENSE.txt + - menuinst/_vendor/apipkg/LICENSE summary: cross platform install of menu items doc_url: https://conda.github.io/menuinst/ diff --git a/tests/test_backwards_compatibility.py b/tests/test_backwards_compatibility.py new file mode 100644 index 00000000..20bb4d77 --- /dev/null +++ b/tests/test_backwards_compatibility.py @@ -0,0 +1,12 @@ +import os + +import pytest + + +@pytest.mark.skipif(os.name != "nt", reason="Windows only") +def test_import_paths(): + "These import paths are/were used by conda <=23.7.2. Ensure they still work." + from menuinst import install # noqa + from menuinst.knownfolders import FOLDERID, get_folder_path # noqa + from menuinst.win32 import dirs_src # noqa + from menuinst.winshortcut import create_shortcut # noqa