diff --git a/docs/requirements.txt b/docs/requirements.txt index 76263e1..4ccdfe6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -18,6 +18,8 @@ charset-normalizer==2.0.7 # via requests click==8.0.3 # via mkdocs +csscompressor==0.9.5 + # via mkdocs-minify-plugin ghp-import==2.0.2 # via mkdocs gitdb==4.0.7 @@ -78,11 +80,11 @@ mkdocs-git-revision-date-localized-plugin==0.10.0 # via -r docs/requirements.in mkdocs-htmlproofer-plugin==0.7.0 # via -r docs/requirements.in -mkdocs-material==7.3.3 +mkdocs-material==7.3.4 # via -r docs/requirements.in mkdocs-material-extensions==1.0.3 # via mkdocs-material -mkdocs-minify-plugin==0.4.1 +mkdocs-minify-plugin==0.5.0 # via -r docs/requirements.in mkdocs-section-index==0.3.2 # via -r docs/requirements.in diff --git a/requirements-dev.in b/requirements-dev.in index 1996ba8..a56e649 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -41,6 +41,7 @@ bandit # Type checkers mypy +types-toml # Formatters black diff --git a/requirements-dev.txt b/requirements-dev.txt index 670cb7f..fad196a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ astor==0.8.1 # via flake8-simplify astpretty==2.1.0 # via flake8-expression-complexity -astroid==2.8.2 +astroid==2.8.3 # via pylint asttokens==2.0.5 # via flake8-aaa @@ -94,13 +94,13 @@ flake8-debugger==4.0.0 # via -r requirements-dev.in flake8-docstrings==1.6.0 # via -r requirements-dev.in -flake8-eradicate==1.1.0 +flake8-eradicate==1.2.0 # via -r requirements-dev.in flake8-expression-complexity==0.0.9 # via -r requirements-dev.in flake8-fixme==1.1.1 # via -r requirements-dev.in -flake8-markdown==0.2.0 +flake8-markdown==0.3.0 # via -r requirements-dev.in flake8-mutable==1.2.0 # via -r requirements-dev.in @@ -116,7 +116,7 @@ flake8-simplify==0.14.2 # via -r requirements-dev.in flake8-typing-imports==1.11.0 # via -r requirements-dev.in -flake8-use-fstring==1.1 +flake8-use-fstring==1.2 # via -r requirements-dev.in flake8-variables-names==0.0.4 # via -r requirements-dev.in @@ -184,7 +184,7 @@ pathspec==0.9.0 # yamllint pbr==5.6.0 # via stevedore -pep517==0.11.0 +pep517==0.12.0 # via pip-tools pep8-naming==0.12.1 # via -r requirements-dev.in @@ -268,7 +268,7 @@ smmap==4.0.0 # gitdb snowballstemmer==2.1.0 # via pydocstyle -stevedore==3.4.0 +stevedore==3.5.0 # via bandit toml==0.10.2 # via @@ -289,6 +289,8 @@ typed-ast==1.4.3 # black # flake8-annotations # mypy +types-toml==0.10.1 + # via -r requirements-dev.in typing-extensions==3.10.0.2 # via # -c docs/requirements.txt @@ -310,7 +312,7 @@ wheel==0.37.0 # via # -c docs/requirements.txt # pip-tools -wrapt==1.12.1 +wrapt==1.13.2 # via astroid yamlfix==0.7.2 # via -r requirements-dev.in diff --git a/src/autoimport/config.py b/src/autoimport/config.py new file mode 100644 index 0000000..90b44db --- /dev/null +++ b/src/autoimport/config.py @@ -0,0 +1,56 @@ +"""Module to hold the `AutoImportConfig` class definition.""" + +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +import toml + +from autoimport.utils import get_pyproject_path + + +class Config: + """Defines the base `Config` and provides accessors to get config values.""" + + def __init__( + self, + config_dict: Optional[Dict[str, Any]] = None, + config_path: Optional[Path] = None, + ) -> None: + """Initialize the config.""" + self._config_dict: Dict[str, Any] = config_dict or {} + self.config_path: Optional[Path] = config_path + + def get_option(self, option: str) -> Optional[str]: + """Return the value of a config option. + + Args: + option (str): the config option for which to return the value + + Returns: + The value of the given config option or `None` if it doesn't exist + """ + return self._config_dict.get(option) + + +class AutoImportConfig(Config): + """Defines the autoimport `Config`.""" + + def __init__(self, starting_path: Optional[Path] = None) -> None: + """Initialize the config.""" + config_path, config_dict = _find_config(starting_path) + super().__init__(config_dict=config_dict, config_path=config_path) + + +def _find_config( + starting_path: Optional[Path] = None, +) -> Tuple[Optional[Path], Dict[str, Any]]: + pyproject_path: Optional[Path] = get_pyproject_path(starting_path) + if pyproject_path: + return pyproject_path, toml.load(pyproject_path).get("tool", {}).get( + "autoimport", {} + ) + + return None, {} + + +autoimport_config: AutoImportConfig = AutoImportConfig() diff --git a/src/autoimport/utils.py b/src/autoimport/utils.py new file mode 100644 index 0000000..04a7d42 --- /dev/null +++ b/src/autoimport/utils.py @@ -0,0 +1,36 @@ +"""Module to hold various utils.""" + +from pathlib import Path +from typing import Optional + +PYPROJECT_FILENAME = "pyproject.toml" + + +def path_contains_pyproject(path: Path) -> bool: + """Determine whether a `pyproject.toml` exists in the given path. + + Args: + path (Path): the path in which to search for the `pyproject.toml` + + Returns: + A boolean to indicate whether a `pyproject.toml` exists in the given path + """ + return (path / PYPROJECT_FILENAME).is_file() + + +def get_pyproject_path(starting_path: Optional[Path] = None) -> Optional[Path]: + """Search for a `pyproject.toml` by traversing up the tree from a path. + + Args: + starting_path (Path): an optional path from which to start searching + + Returns: + The `Path` to the `pyproject.toml` if it exists or `None` if it doesn't + """ + start: Path = starting_path or Path.cwd() + + for path in [start, *start.parents]: + if path_contains_pyproject(path): + return path / PYPROJECT_FILENAME + + return None diff --git a/tests/conftest.py b/tests/conftest.py index 066426a..1b8f7a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,20 @@ """Store the classes and fixtures used throughout the tests.""" + +from pathlib import Path +from typing import Callable, Optional + +import pytest + + +@pytest.fixture() +def create_tmp_file(tmp_path: Path) -> Callable: + """Fixture for creating a temporary file.""" + + def _create_tmp_file( + content: Optional[str] = "", filename: Optional[str] = "file.txt" + ) -> Path: + tmp_file = tmp_path / filename + tmp_file.write_text(content) + return tmp_file + + return _create_tmp_file diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e69de29..6ac8b88 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -0,0 +1,109 @@ +"""Tests for the `Config` classes.""" + +from pathlib import Path +from typing import Callable + +import toml + +from autoimport.config import AutoImportConfig, Config + + +class TestConfig: + """Tests for the `Config` class.""" + + def test_get_valid_option(self) -> None: + """ + Given: a `Config` instance with a `config_dict` populated, + When a value is retrieved for an existing option, + Then the value of the option is returned + """ + config_dict = {"foo": "bar"} + config = Config(config_dict=config_dict) + + result = config.get_option("foo") + + assert result == "bar" + + def test_get_value_for_missing_option(self) -> None: + """ + Given: a `Config` instance with a `config_dict` populated, + When: a value is retrieved for a option not defined in the `config_dict`, + Then: `None` is returned + """ + config_dict = {"foo": "bar"} + config = Config(config_dict=config_dict) + + result = config.get_option("baz") + + assert result is None + + def test_get_value_for_no_config_dict(self) -> None: + """ + Given: a `Config` instance without a given `config_dict`, + When: a value is retrieved for an option, + Then: `None` is returned + """ + config = Config() + + result = config.get_option("foo") + + assert result is None + + def test_given_config_path(self) -> None: + """ + Given: a `Config` instance with a given `config_path`, + When: the `config_path` attribute is retrieved, + Then: the given `config_path` is returned + """ + config_path = Path("/") + config = Config(config_path=config_path) + + result = config.config_path + + assert result is config_path + + +class TestAutoImportConfig: + """Tests for the `AutoImportConfig`.""" + + def test_valid_pyproject(self, create_tmp_file: Callable) -> None: + """ + Given: a valid `pyproject.toml`, + When: the `AutoImportConfig` class is instantiated, + Then: a config value can be retrieved + """ + config_toml = toml.dumps({"tool": {"autoimport": {"foo": "bar"}}}) + pyproject_path = create_tmp_file(content=config_toml, filename="pyproject.toml") + autoimport_config = AutoImportConfig(starting_path=pyproject_path) + + result = autoimport_config.get_option("foo") + + assert result == "bar" + + def test_no_pyproject(self) -> None: + """ + Given: no supplied `pyproject.toml`, + When: the `AutoImportConfig` class is instantiated, + Then: the situation is handled gracefully + """ + autoimport_config = AutoImportConfig(starting_path=Path("/")) + + result = autoimport_config.get_option("foo") + + assert result is None + + def test_valid_pyproject_with_no_autoimport_section( + self, create_tmp_file: Callable + ) -> None: + """ + Given: a valid `pyproject.toml`, + When: the `AutoImportConfig` class is instantiated, + Then: a config value can be retrieved + """ + config_toml = toml.dumps({"foo": "bar"}) + pyproject_path = create_tmp_file(content=config_toml, filename="pyproject.toml") + autoimport_config = AutoImportConfig(starting_path=pyproject_path) + + result = autoimport_config.get_option("foo") + + assert result is None diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..1d712e3 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,90 @@ +"""Tests for the `utils` module.""" + +from pathlib import Path +from typing import Callable +from unittest.mock import MagicMock, patch + +from autoimport.utils import get_pyproject_path, path_contains_pyproject + + +class TestContainsPyproject: + """Tests for the `contains_pyproject` function""" + + def test_pyproject_found(self, create_tmp_file: Callable) -> None: + """ + Given: a path containing a `pyproject.toml` file, + When: the `contains_pyproject` function is invoked with the path, + Then: a `True` is returned + """ + path = create_tmp_file(filename="pyproject.toml") + + result = path_contains_pyproject(path.parent) + + assert result is True + + def test_pyproject_not_found(self, create_tmp_file: Callable) -> None: + """ + Given: a path not containing a `pyproject.toml` file, + When: the `contains_pyproject` function is invoked with the path, + Then: a `False` is returned + """ + path = create_tmp_file(filename="foo.toml") + + result = path_contains_pyproject(path.parent) + + assert result is False + + +class TestGetPyprojectPath: + """Tests for the `get_pyproject_path`""" + + @patch("autoimport.utils.Path", autospec=True) + def test_in_current_directory( + self, mock_path: MagicMock, create_tmp_file: Callable + ) -> None: + """ + Given: a `pyproject.toml` file in the `cwd`, + When: the `get_pyproject_path` function is invoked without a `starting_path`, + Then: the path to the `pyproject.toml` is returned + """ + path_to_pyproject = create_tmp_file(filename="pyproject.toml") + mock_path.cwd.return_value = path_to_pyproject.parent + + result = get_pyproject_path() + + assert result == path_to_pyproject + + def test_in_parent_directory(self, create_tmp_file: Callable) -> None: + """ + Given: a `pyproject.toml` file in the parent of `cwd`, + When: the `get_pyproject_path` function is invoked, + Then: the path to the `pyproject.toml` is returned + """ + path_to_pyproject = create_tmp_file(filename="pyproject.toml") + sub_dir = path_to_pyproject / "sub" + + result = get_pyproject_path(sub_dir) + + assert result == path_to_pyproject + + def test_not_found(self) -> None: + """ + Given: no `pyproject.toml` in the `cwd` or parent dirs, + When: the `get_pyproject_path` function is invoked, + Then: `None` is returned + """ + result = get_pyproject_path(Path("/nowhere")) + + assert result is None + + def test_with_given_path(self, create_tmp_file: Callable) -> None: + """ + Given: a `pyproject.toml` file in a path, + When: the `get_pyproject_path` function is invoked with a `starting_path`, + Then: the path to the `pyproject.toml` is returned + """ + path_to_pyproject = create_tmp_file(filename="pyproject.toml") + + result = get_pyproject_path(starting_path=path_to_pyproject) + + assert result == path_to_pyproject