Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[flake8]
max-line-length = 120
per-file-ignores =
tests/test_typing.py:E501,F841
tests/test_cast.py:E501,F841
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ repos:
entry: flake8
language: system
types: [python]
args: [--max-line-length=88]
exclude: docs
- id: isort
name: isort
Expand All @@ -32,7 +31,7 @@ repos:
entry: mypy
language: system
types: [python]
exclude: docs
exclude: docs/.*|tests/.*
- id: pylint
name: pylint
entry: pylint
Expand Down
15 changes: 12 additions & 3 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
[mypy]

ignore_missing_imports = True
follow_imports = skip
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
disallow_any_generics = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_unreachable = True
strict_equality = True
show_error_codes = True
4 changes: 2 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


@nox.session(reuse_venv=True, python="3.8")
def cop(session):
def cop(session) -> None: # type: ignore
"""Run all pre-commit hooks."""
session.install(".")
session.install(".[dev,test]")
Expand All @@ -14,7 +14,7 @@ def cop(session):


@nox.session(reuse_venv=True, python="3.8")
def tests(session):
def tests(session) -> None: # type: ignore
"""Run all tests."""
session.install(".")
session.install(".[test]")
Expand Down
11 changes: 8 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
from setuptools import setup


def get_version():
def get_version() -> str:
init_f = Path(__file__).parent / "youconfigme" / "__init__.py"
with open(init_f) as f:
for line in f:
if "__version__" in line:
return line.split("=")[-1].strip().strip('"')
raise ValueError("Version not found")


def read_readme():
def read_readme() -> str:
readme_f = Path(__file__).parent / "README.md"
with open(readme_f) as f:
return f.read()
Expand All @@ -29,7 +30,7 @@ def read_readme():
author="CrossNox",
install_requires=["toml"],
extras_require={
"test": ["pytest"],
"test": ["pytest", "pytest-mypy-testing", "pytest-cov"],
"dev": [
"pre-commit",
"mypy",
Expand All @@ -40,8 +41,12 @@ def read_readme():
"bump",
"nox",
"types-toml",
"types-setuptools",
],
},
packages=["youconfigme"],
package_data={
"youconfigme": ["py.typed", "*.pyi"],
},
classifiers=["Programming Language :: Python :: 3"],
)
64 changes: 64 additions & 0 deletions tests/test_cast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Test cast functions"""

# pylint: disable=redefined-outer-name
# pylint: disable=missing-function-docstring
# pylint: disable=unused-variable

import pytest
from youconfigme import Config
from youconfigme.common_casts import to_bool

# fmt: off


@pytest.fixture
def config_dict() -> Config:
return Config(
from_items={
"yes": {
"k1": "yes",
"k2": "true",
"k3": "t",
"k4": "1",
"k5": "True",
"k6": True,
},
"no": {
"k1": "no",
"k2": "false",
"k3": "f",
"k4": "0",
"k5": "False",
"k6": False,
},
}
)


def test_to_bool(config_dict: Config) -> None:
"""Test to_bool"""
assert config_dict.yes.k1(cast=to_bool) is True
assert config_dict.yes.k2(cast=to_bool) is True
assert config_dict.yes.k3(cast=to_bool) is True
assert config_dict.yes.k4(cast=to_bool) is True
assert config_dict.yes.k5(cast=to_bool) is True
assert config_dict.yes.k6(cast=to_bool) is True

assert config_dict.no.k1(cast=to_bool) is False
assert config_dict.no.k2(cast=to_bool) is False
assert config_dict.no.k3(cast=to_bool) is False
assert config_dict.no.k4(cast=to_bool) is False
assert config_dict.no.k5(cast=to_bool) is False
assert config_dict.no.k6(cast=to_bool) is False


@pytest.mark.mypy_testing
def test_to_bool_types(config_dict: Config) -> None:
"""Test typing"""
x: bool = config_dict.yes.k1(cast=to_bool)


@pytest.mark.mypy_testing
def test_to_bool_bad_types(config_dict: Config) -> None:
"""Test typing"""
x: str = config_dict.yes.k1(cast=to_bool) # E: Incompatible types in assignment (expression has type "bool", variable has type "str") [assignment]
2 changes: 1 addition & 1 deletion tests/test_config_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def test_val_nex(config_section):


def test_val_nex_def(config_section):
assert config_section.w(7) == "7"
assert config_section.w(7) == 7


def test_val_nex_def_cast(config_section):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_default_none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Test cast functions"""

# pylint: disable=redefined-outer-name
# pylint: disable=missing-function-docstring
# pylint: disable=unused-variable

import pytest
from youconfigme import Config, ConfigItemNotFound


def test_default_none() -> None:
config = Config(from_items={"a": {"b": 1}})
with pytest.raises(ConfigItemNotFound):
config.a.c()

assert config.a.c(default=None) is None
16 changes: 8 additions & 8 deletions tests/test_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,29 @@ def config_underscore():
del os.environ["K4"]


@patch("youconfigme.getpass.subprocess.run")
@patch("youconfigme.use_gnupass.subprocess.run")
def test_k1(mock_run, config_underscore):
mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS
mock_run.return_value.stdout = PASS + "\n"
cfg = config_underscore
assert cfg.a.k1(from_pass=True) == PASS


@patch("youconfigme.getpass.subprocess.run")
@patch("youconfigme.use_gnupass.subprocess.run")
def test_k2(mock_run, config_underscore):
mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS
mock_run.return_value.stdout = PASS + "\n"
cfg = config_underscore
assert cfg.a.k2(from_pass=True) == PASS


@patch("youconfigme.getpass.subprocess.run")
@patch("youconfigme.use_gnupass.subprocess.run")
def test_k3(mock_run, config_underscore):
mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS
mock_run.return_value.stdout = PASS + "\n"
cfg = config_underscore
assert cfg.k3(from_pass=True) == PASS


@patch("youconfigme.getpass.subprocess.run")
@patch("youconfigme.use_gnupass.subprocess.run")
def test_k4(mock_run, config_underscore):
mock_run.return_value.stdout.decode.return_value.strip.return_value = PASS
mock_run.return_value.stdout = PASS + "\n"
cfg = config_underscore
assert cfg.k4(from_pass=True) == PASS
141 changes: 141 additions & 0 deletions tests/test_typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Type checking tests for youconfigme using pytest-mypy-testing."""

# pylint: disable=redefined-outer-name
# pylint: disable=missing-function-docstring
# pylint: disable=unused-variable

from typing import Dict, List, Union

import pytest
from youconfigme import Config, ConfigSection

# fmt: off


@pytest.mark.mypy_testing
def test_correct_basic_usage() -> None:
"""Test correct basic usage - should produce no errors."""
config = Config(from_items={"db": {"host": "localhost", "port": "5432"}})

# These should all work without errors
host: str = config.db.host()
port_str: str = config.db.port()
port_int: int = config.db.port(cast=int)

# With defaults
timeout: str = config.db.timeout(default="30")
timeout_int: int = config.db.timeout(default="30", cast=int)
timeout_int_2: int = config.db.timeout(default=30, cast=int)

# Correct casting functions

def to_int(value: str) -> int:
return int(value)

timeout_int_3: int = config.db.port(default="30", cast=to_int)
timeout_int_4: int = config.db.port(default=30, cast=to_int)
timeout_int_5: int = config.db.port(cast=to_int)


@pytest.mark.mypy_testing
def test_wrong_type_assignment_without_cast() -> None:
"""Test wrong type assignment without cast - should produce error."""
config = Config(from_items={"test": {"value": "123"}})

# This should produce a type error
wrong: int = config.test.value() # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]


@pytest.mark.mypy_testing
def test_invalid_cast_function_signature() -> None:
"""Test cast function with wrong input type - should produce error."""
config = Config(from_items={"test": {"value": "123"}})

# Cast function should take str, not int
def bad_cast(value: int) -> str:
return str(value)

# This should produce error about incompatible argument type
result = config.test.value(cast=bad_cast) # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[int], str]"; expected "Callable[[str], str]" [arg-type]


@pytest.mark.mypy_testing
def test_cast_return_type_mismatch() -> None:
"""Test mismatch between cast return type and variable type."""
config = Config(from_items={"test": {"numbers": "a,b,c"}})

def parse_to_list(value: str) -> List[str]:
return value.split(",")

# Expecting List[int] but cast returns List[str]
numbers: List[int] = config.test.numbers(cast=parse_to_list) # E: Argument "cast" to "__call__" of "ConfigAttribute" has incompatible type "Callable[[str], list[str]]"; expected "Callable[[str], list[int]]" [arg-type]


@pytest.mark.mypy_testing
def test_cast_return_type_mismatch_simple() -> None:
"""Test mismatch between cast return type and variable type."""
config = Config(from_items={"test": {"number": "1"}})

number: str = config.test.number(cast=int) # E: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment]


@pytest.mark.mypy_testing
def test_bad_default_type_without_cast() -> None:
"""Test that default without cast doesn't convert types."""
config = Config(from_items={})

# Default is string "100", without cast it stays string
port: int = config.server.port(default="100") # E: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]

# This works because we use cast
port_ok: int = config.server.port(default="100", cast=int)


@pytest.mark.mypy_testing
def test_correct_custom_cast_functions() -> None:
"""Test various custom cast functions work correctly."""
config = Config(from_items={
"app": {
"debug": "true",
"features": "auth,api,admin",
"max_connections": "100"
}
})

def str_to_bool(value: str) -> bool:
return value.lower() in ("true", "1", "yes")

def str_to_list(value: str) -> List[str]:
return [item.strip() for item in value.split(",")]

# These should all type check correctly
debug: bool = config.app.debug(cast=str_to_bool)
features: List[str] = config.app.features(cast=str_to_list)
connections: int = config.app.max_connections(cast=int)


@pytest.mark.mypy_testing
def test_config_section_typing() -> None:
"""Test ConfigSection type and methods."""
config = Config(from_items={"cache": {"ttl": "3600", "size": "1000"}})

# ConfigSection access
cache_section: ConfigSection = config.cache

# to_dict returns Dict[str, str]
cache_dict: Dict[str, str] = cache_section.to_dict()

# Wrong type for to_dict
wrong_dict: Dict[str, int] = cache_section.to_dict() # E: Incompatible types in assignment (expression has type "dict[str, str]", variable has type "dict[str, int]") [assignment]


@pytest.mark.mypy_testing
def test_config_to_dict_return_type() -> None:
"""Test Config.to_dict() return type."""
config = Config(from_items={
"server": {"host": "0.0.0.0", "port": "8080"},
"db": {"name": "mydb"}
})

# to_dict returns Dict[str, Union[Dict[str, str], str]]
config_dict: Dict[str, Union[Dict[str, str], str]] = config.to_dict()
2 changes: 1 addition & 1 deletion youconfigme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

from .youconfigme import AutoConfig, Config, ConfigItemNotFound, ConfigSection

__version__ = "1.0.0"
__version__ = "2.0.0"
__all__ = ["AutoConfig", "Config", "ConfigItemNotFound", "ConfigSection"]
Loading