Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read config from pyproject.toml #152

Merged
merged 5 commits into from Oct 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/requirements.txt
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.in
Expand Up @@ -41,6 +41,7 @@ bandit

# Type checkers
mypy
types-toml
lyz-code marked this conversation as resolved.
Show resolved Hide resolved

# Formatters
black
Expand Down
16 changes: 9 additions & 7 deletions requirements-dev.txt
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions 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 {}
lyz-code marked this conversation as resolved.
Show resolved Hide resolved
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()
36 changes: 36 additions & 0 deletions 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
19 changes: 19 additions & 0 deletions 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
109 changes: 109 additions & 0 deletions 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