From 9c3de73333323105f97417ac346750223801edfc Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Mon, 22 Apr 2024 10:19:52 -0400 Subject: [PATCH] Initial support for persistent user settings (#58) Includes: - pypi index aliases (#132) - default conda format --- CHANGELOG.md | 7 + mkdocs.yml | 4 +- src/whl2conda/VERSION | 3 +- src/whl2conda/cli/config.py | 50 ++++++- src/whl2conda/cli/convert.py | 3 +- src/whl2conda/cli/main.py | 13 +- src/whl2conda/impl/download.py | 30 ++++ src/whl2conda/impl/pyproject.py | 2 +- src/whl2conda/settings.py | 241 ++++++++++++++++++++++++++++++++ test/conftest.py | 12 +- test/test_settings.py | 165 ++++++++++++++++++++++ 11 files changed, 522 insertions(+), 8 deletions(-) create mode 100644 src/whl2conda/settings.py create mode 100644 test/test_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de1820..a6739a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # whl2conda changes +## [24.1.1] - *in progress* +### Features +* Added persistent user settings for: + * default conda format + * whether to automatically update stdrenames table + * specify aliases for extra pypi indexes + ## [24.4.0] - 2024-4-14 ### Changes * Only use classic installer in `whl2conda install` environments if diff --git a/mkdocs.yml b/mkdocs.yml index bae6165..5d06153 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,8 +30,8 @@ theme: hljs_languages: python json - logo: whl2conda.svg - favicon: whl2conda.svg + logo: whl2conda.jpg + favicon: whl2conda.jpg palette: - media: "(prefers-color-scheme: light)" scheme: default diff --git a/src/whl2conda/VERSION b/src/whl2conda/VERSION index ee3b424..2caa72e 100644 --- a/src/whl2conda/VERSION +++ b/src/whl2conda/VERSION @@ -1 +1,2 @@ -24.4.0 +24.4.1 + diff --git a/src/whl2conda/cli/config.py b/src/whl2conda/cli/config.py index 309e021..80663a3 100644 --- a/src/whl2conda/cli/config.py +++ b/src/whl2conda/cli/config.py @@ -1,4 +1,4 @@ -# Copyright 2023 Christopher Barber +# Copyright 2023-2024 Christopher Barber # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import json import sys from http.client import HTTPException from pathlib import Path @@ -29,6 +30,7 @@ from .common import add_markdown_help, dedent from ..impl.pyproject import add_pyproject_defaults from ..api.stdrename import user_stdrenames_path +from ..settings import settings __all__ = ["config_main"] @@ -76,6 +78,26 @@ def config_main( %(const)s. """), ) + + settings_opts = parser.add_argument_group("user settings options") + + settings_opts.add_argument( + "--remove", metavar="", help="Unset user setting with given key." + ) + settings_opts.add_argument( + "--set", + metavar=("", ""), + nargs=2, + ) + + settings_opts.add_argument( + "--show", + metavar="", + nargs="?", + const="", + help=f"Show user settings from {settings._settings_file}", + ) + parser.add_argument( "-n", "--dry-run", @@ -95,6 +117,15 @@ def config_main( except Exception as ex: # pylint: disable=broad-exception-caught parser.error(str(ex)) + if parsed.remove: + remove_user_setting(parsed.remove) + + if parsed.set: + set_user_setting(*parsed.set) + + if parsed.show is not None: + show_user_settings(parsed.show) + def update_std_renames(renames_file: Path, *, dry_run: bool) -> None: """ @@ -124,5 +155,22 @@ def update_std_renames(renames_file: Path, *, dry_run: bool) -> None: sys.exit(0) +def set_user_setting(key: str, value: str) -> None: + settings.set(key, value) + settings.save() + + +def show_user_settings(key: str) -> None: + if key: + print(f"{key}: {json.dumps(settings.get(key))}") + else: + print(json.dumps(settings.to_dict(), indent=2)) + + +def remove_user_setting(key: str) -> None: + settings.unset(key) + settings.save() + + if __name__ == "__main__": # pragma: no cover config_main() diff --git a/src/whl2conda/cli/convert.py b/src/whl2conda/cli/convert.py index 3c47823..9351df5 100644 --- a/src/whl2conda/cli/convert.py +++ b/src/whl2conda/cli/convert.py @@ -31,6 +31,7 @@ from ..impl.prompt import is_interactive, choose_wheel from ..api.converter import Wheel2CondaConverter, CondaPackageFormat, DependencyRename from ..impl.pyproject import read_pyproject, PyProjInfo +from ..settings import settings from .common import ( add_markdown_help, dedent, @@ -452,7 +453,7 @@ def convert_main(args: Optional[Sequence[str]] = None, prog: Optional[str] = Non elif pyproj_info.conda_format: out_fmt = pyproj_info.conda_format else: - out_fmt = CondaPackageFormat.V2 + out_fmt = settings.conda_format if verbosity < -1: level = logging.ERROR diff --git a/src/whl2conda/cli/main.py b/src/whl2conda/cli/main.py index c3c7a7f..a2b4ebe 100644 --- a/src/whl2conda/cli/main.py +++ b/src/whl2conda/cli/main.py @@ -1,4 +1,4 @@ -# Copyright 2023 Christopher Barber +# Copyright 2023-2024 Christopher Barber # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,10 +20,12 @@ import argparse import sys +from pathlib import Path from typing import Optional, Sequence from ..__about__ import __version__ from .common import dedent, Subcommands, add_markdown_help +from ..settings import settings __all__ = ["main"] @@ -83,11 +85,20 @@ def __call__(self, *args, **kwargs): "--list-subcommands", action=ListSubcommands, nargs=0, help=argparse.SUPPRESS ) + parser.add_argument( + "--settings", + metavar="", + help="Override default settings file", + ) + add_markdown_help(parser) parser.add_argument("--version", action="version", version=__version__) parsed = parser.parse_args(args) + if parsed.settings: + settings.load(Path(parsed.settings).expanduser()) + subcmds.run(parsed) diff --git a/src/whl2conda/impl/download.py b/src/whl2conda/impl/download.py index 5819b88..d8e1882 100644 --- a/src/whl2conda/impl/download.py +++ b/src/whl2conda/impl/download.py @@ -18,6 +18,7 @@ from __future__ import annotations +import configparser import shutil import subprocess import sys @@ -25,11 +26,38 @@ from pathlib import Path from typing import Optional +from ..settings import settings + __all__ = [ "download_wheel", + "lookup_pypi_index", ] +def lookup_pypi_index(index: str) -> str: + """ + Translate index aliases + + First looks for exact match in user settings + pyproject_indexes table and then looks for + matching entry in the ~/.pypirc file. + + Otherwise returns the original string + """ + if new_index := settings.pypi_indexes.get(index): + return new_index + + pypirc_path = Path("~/.pypirc").expanduser() + if pypirc_path.exists(): + pypirc = configparser.ConfigParser() + pypirc.read(pypirc_path) + try: + return pypirc[index]["repository"] + except Exception: + pass + return index + + def download_wheel( spec: str, index: str = "", @@ -61,6 +89,8 @@ def download_wheel( "--implementation", "py", ] + if index: + index = lookup_pypi_index(index) if index: cmd.extend(["-i", index]) cmd.extend(["-d", str(tmpdirname)]) diff --git a/src/whl2conda/impl/pyproject.py b/src/whl2conda/impl/pyproject.py index 60a8b58..f9fdcc6 100644 --- a/src/whl2conda/impl/pyproject.py +++ b/src/whl2conda/impl/pyproject.py @@ -34,7 +34,7 @@ ] -class CondaPackageFormat(enum.Enum): +class CondaPackageFormat(str, enum.Enum): """ Supported output package formats diff --git a/src/whl2conda/settings.py b/src/whl2conda/settings.py new file mode 100644 index 0000000..71e4ced --- /dev/null +++ b/src/whl2conda/settings.py @@ -0,0 +1,241 @@ +# Copyright 2024 Christopher Barber +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Settings for whl2conda. +""" + +from __future__ import annotations + +# standard +import dataclasses +import datetime as dt +import json +from pathlib import Path +from typing import Any, Callable, ClassVar, NamedTuple, Union + +# third party +from platformdirs import user_config_path + +# this project +from .__about__ import __version__ +from .impl.pyproject import CondaPackageFormat + +__all__ = ["Whl2CondaSettings", "settings"] + + +class FieldDefault(NamedTuple): + factory: Callable + + +class StringConversionField: + def __init__(self, *, default): + if callable(default): + self._default = FieldDefault(default) + else: + self._default = default + + def __set_name__(self, owner, name): + self._name = "_" + name + + def __get__(self, obj, type): + if obj is None: + return self._default + return getattr(obj, self._name) + + def __set__(self, obj, value): + if isinstance(value, FieldDefault): + value = value.factory() + elif isinstance(value, str): + value = self._convert_from_str(value) + setattr(obj, self._name, value) + + def __delete__(self, obj): + self.__set__(obj, self._default) + + def _convert_from_str(self, value: str): + return value + + +class BoolField(StringConversionField): + def __init__(self, *, default: bool = False): + super().__init__(default=default) + + def _convert_from_str(self, value: str): + value = value.lower() + if value in {"true", "t", "yes", "y"}: + return True + elif value in {"false", "f", "no", "n"}: + return False + else: + raise ValueError(f"Invalid value {value!r} for bool field") + + +class CondaPackageFormatField(StringConversionField): + def __init__(self): + super().__init__(default=CondaPackageFormat.V2) + + def _convert_from_str(self, value: str): + return CondaPackageFormat.from_string(value) + + +def toidentifier(name: str) -> str: + return name.replace("-", "_") + + +def fromidentifier(name: str) -> str: + return name.replace("_", "-") + + +@dataclasses.dataclass(kw_only=True) +class Whl2CondaSettings: + SETTINGS_FILENAME: ClassVar[str] = "whl2conda.json" + DEFAULT_SETTINGS_FILE: ClassVar[Path] = user_config_path() / SETTINGS_FILENAME + + # TODO: + # - difftool + # - pyproject defaults + + auto_update_std_renames: BoolField = BoolField() + + conda_format: CondaPackageFormatField = CondaPackageFormatField() + + pypi_indexes: StringConversionField = StringConversionField(default=dict) + + # + # Internal attributes + # + + _settings_file: Path = dataclasses.field( + default=DEFAULT_SETTINGS_FILE, compare=False + ) + + _fieldnames: ClassVar[frozenset[str]] = frozenset() + + @property + def settings_file(self) -> Path: + """ + Settings file for this settings object. + + Set from [from_file][..] constructor or else will be + [DEFAULT_SETTINGS_FILE][(c).]. + """ + return self._settings_file + + def to_dict(self) -> dict[str, Any]: + return { + k: v for k, v in dataclasses.asdict(self).items() if not k.startswith("_") + } + + def get(self, key: str) -> Any: + name, subkey = self._split_key(key) + value = getattr(self, name) + + if subkey: + if not isinstance(value, dict): + raise KeyError( + f"Bad settings key '{key}': '{name}' is not a dictionary" + ) + if subkey not in value: + raise KeyError(f"'{key}' is not set'") + value = value[subkey] + + return value + + def set(self, key: str, value: Any) -> None: + name, subkey = self._split_key(key) + + if not subkey: + setattr(self, name, value) + + else: + d = getattr(self, name) + if not isinstance(d, dict): + raise KeyError( + f"Bad settings key '{key}': '{name}' is not a dictionary" + ) + d[subkey] = value + + def unset(self, key: str) -> None: + """ + Unset attribute with given key. + + The setting will revert to its original value. + """ + name, subkey = self._split_key(key) + + if not subkey: + delattr(self, name) + + else: + d = getattr(self, name) + if not isinstance(d, dict): + raise KeyError( + f"Bad settings key '{key}': '{name}' is not a dictionary" + ) + try: + del d[subkey] + except KeyError: + pass + + def _split_key(self, key: str) -> tuple[str, str]: + parts = key.split(".", maxsplit=1) + name = toidentifier(parts[0]) + if name not in self._fieldnames: + raise KeyError(f"Unknown settings key '{key}'") + return name, parts[1] if len(parts) > 1 else "" + + @classmethod + def from_file(cls, filename: Union[Path, str] = "") -> Whl2CondaSettings: + settings = cls() + settings.load(filename or cls.DEFAULT_SETTINGS_FILE) + return settings + + def load(self, filename: Union[Path, str], reset_all: bool = False) -> None: + filepath = Path(Path(filename).expanduser()) + self._settings_file = filepath + if reset_all: + self.unset_all() + if filepath.exists(): + contents = filepath.read_text("utf8") + json_obj = json.loads(contents) + for k, v in json_obj.items(): + if k in self._fieldnames: + setattr(self, k, v) + + def save(self, filename: Union[Path, str] = "") -> None: + """ + Write settings to specified file in JSON format. + Args: + filename: file to write. Defaults to [settings_file][..] + """ + filepath = Path(filename or self._settings_file) + json_obj = self.to_dict() + json_obj["$whl2conda-version"] = __version__ + json_obj["$created"] = str(dt.datetime.now()) + filepath.write_text(json.dumps(json_obj, indent=2)) + + def unset_all(self) -> None: + """ + Unset all settings, and revert to default values. + """ + for k in self._fieldnames: + self.unset(k) + + +Whl2CondaSettings._fieldnames = frozenset( + f.name for f in dataclasses.fields(Whl2CondaSettings) if not f.name.startswith("_") +) + +settings = Whl2CondaSettings.from_file() diff --git a/test/conftest.py b/test/conftest.py index 2162b63..85baae1 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2023 Christopher Barber +# Copyright 2023-2024 Christopher Barber # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,6 +23,16 @@ import pytest +from whl2conda.settings import settings + + +@pytest.fixture(autouse=True) +def clear_settings(): + """ + Fixture clears user settings before each test + """ + settings.unset_all() + def pytest_addoption(parser): """ diff --git a/test/test_settings.py b/test/test_settings.py new file mode 100644 index 0000000..7067f93 --- /dev/null +++ b/test/test_settings.py @@ -0,0 +1,165 @@ +# Copyright 2024 Christopher Barber +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Unit tests for whl2conda.settings module +""" + +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +from whl2conda.impl.pyproject import CondaPackageFormat +from whl2conda.settings import Whl2CondaSettings, fromidentifier + +# ruff: noqa: F811 + + +def test_Whl2CondaSettings(tmp_path: Path): + """ + Unit test for Whl2CondaSettings class + """ + + settings = Whl2CondaSettings() + + # check defaults + assert settings.settings_file + assert settings.auto_update_std_renames is False + assert settings.conda_format is CondaPackageFormat.V2 + assert settings.pypi_indexes == {} + + check_settings(settings, tmp_path) + + settings.auto_update_std_renames = True + assert settings.auto_update_std_renames is True + settings.auto_update_std_renames = "Yes" + assert settings.auto_update_std_renames is True + with pytest.raises(ValueError): + settings.auto_update_std_renames = "bogus" + + settings.conda_format = "V1" + assert settings.conda_format is CondaPackageFormat.V1 + + settings.pypi_indexes["somewhere"] = "https://somewhere.com/pypi" + assert settings.pypi_indexes == {"somewhere": "https://somewhere.com/pypi"} + check_settings(settings, tmp_path) + + +def test_settings_get() -> None: + """Unit test for Whl2CondaSettings.get method""" + settings = Whl2CondaSettings() + + settings.auto_update_std_renames = True + assert settings.get("auto-update-std-renames") is True + + with pytest.raises(KeyError, match=r"Unknown settings key 'barf'"): + settings.get("barf") + + assert settings.get("pypi_indexes") is settings.pypi_indexes + + with pytest.raises(KeyError, match="'pypi-indexes.somewhere' is not set"): + settings.get("pypi-indexes.somewhere") + + with pytest.raises(KeyError, match="Bad settings key 'conda_format.value'"): + settings.get("conda_format.value") + + settings.pypi_indexes["somewhere"] = "https://somewhere.com/pypi" + assert settings.get("pypi_indexes.somewhere") == "https://somewhere.com/pypi" + + +def test_settings_set(tmp_path: Path) -> None: + """Unit test for Whl2CondaSettings.set method""" + settings = Whl2CondaSettings() + + settings.set("auto-update-std-renames", True) + assert settings.auto_update_std_renames is True + + settings.set("auto-update-std-renames", "no") + assert settings.auto_update_std_renames is False + + settings.set("pypi-indexes.somewhere", "https://somewhere.com/pypi") + assert settings.pypi_indexes == {"somewhere": "https://somewhere.com/pypi"} + + settings.set("pypi-indexes.somewhere-else", "https://other.com/pypi") + assert settings.pypi_indexes == { + "somewhere": "https://somewhere.com/pypi", + "somewhere-else": "https://other.com/pypi", + } + + settings.set("pypi-indexes.somewhere", "https://whoops") + assert settings.pypi_indexes == { + "somewhere": "https://whoops", + "somewhere-else": "https://other.com/pypi", + } + + with pytest.raises(KeyError, match="'conda_format' is not a dictionary"): + settings.set("conda_format.value", "V2") + + check_settings(settings, tmp_path) + + +def test_settings_unset(tmp_path: Path) -> None: + """ + Unit test for Whl2CondaSettings.unset method + """ + settings = Whl2CondaSettings() + + settings.conda_format = "V1" + assert settings.conda_format is CondaPackageFormat.V1 + + settings.pypi_indexes["somewhere"] = "https://somewhere.com/pypi" + settings.pypi_indexes["nowhere"] = "https://nowhere.com/pypi" + + settings.unset("pypi-indexes.somewhere") + assert settings.pypi_indexes == {"nowhere": "https://nowhere.com/pypi"} + + settings.unset("pypi-indexes") + assert settings.pypi_indexes == {} + + settings.unset("conda_format") + assert settings.conda_format is CondaPackageFormat.V2 + + # no error + settings.unset("pypi-indexes.notset") + + with pytest.raises(KeyError, match="'conda_format' is not a dictionary"): + settings.unset("conda_format.value") + + +def check_settings(settings: Whl2CondaSettings, tmp_path: Path) -> None: + """ + Check invariants on Whl2CondaSettings instance + """ + fname = tempfile.mktemp(prefix="whl2conda", suffix=".json", dir=tmp_path) + settings.save(fname) + settings2 = Whl2CondaSettings.from_file(fname) + assert settings2.settings_file == Path(fname) + assert settings == settings2 + + for name in Whl2CondaSettings._fieldnames: + assert settings.get(fromidentifier(name)) == getattr(settings, name) + + settings_dict = settings.to_dict() + + def _check_dict(d: dict[str, Any], prefix="") -> None: + for k, v in d.items(): + key = f"{prefix}{k}" + if isinstance(v, dict): + _check_dict(v, prefix=key + ".") + else: + assert settings.get(key) == v + + _check_dict(settings_dict)