Skip to content

Commit

Permalink
Merge pull request #152 from dbatten5/read-config-from-pyproject
Browse files Browse the repository at this point in the history
Read config from pyproject.toml
  • Loading branch information
lyz-code committed Oct 21, 2021
2 parents 7cd0fab + 0597d39 commit 21eb2bd
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 9 deletions.
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

# 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 {}
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

0 comments on commit 21eb2bd

Please sign in to comment.