1
Fork 0
mirror of https://github.com/Steffo99/cfig.git synced 2024-10-16 14:27:38 +00:00

💥 Many things

This commit is contained in:
Steffo 2022-04-19 03:03:13 +02:00
parent b41baffedc
commit 302b995867
Signed by: steffo
GPG key ID: 6965406171929D01
7 changed files with 241 additions and 12 deletions

View file

@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Sample" type="PythonConfigurationType" factoryName="Python">
<module name="cfig" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="MY_REQUIRED_STRING" value="a" />
<env name="MY_REQUIRED_INT" value="not a number" />
</envs>
<option name="SDK_HOME" value="$PROJECT_DIR$/.venv/bin/python" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/cfig/sample" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="cfig.sample" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="true" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View file

@ -6,6 +6,7 @@ import lazy_object_proxy
import typing as t import typing as t
import logging import logging
import collections import collections
import textwrap
from . import errors from . import errors
from . import customtyping as ct from . import customtyping as ct
from cfig.sources.base import Source from cfig.sources.base import Source
@ -274,6 +275,64 @@ class Configuration:
log.debug(f"Registering doc {doc!r} in {key!r}") log.debug(f"Registering doc {doc!r} in {key!r}")
self.docs[key] = doc self.docs[key] = doc
def _click_root(self):
"""
Generate the :mod:`click` root of this :class:`.Configuration`.
"""
try:
import click
except ImportError:
raise errors.MissingDependencyError(f"To use {self.__class__.__qualname__}.cli, the `cli` optional dependency is needed.")
@click.command()
def root():
click.secho(f"=== Configuration ===", fg="bright_white", bold=True)
click.secho()
key_padding = max(map(lambda k: len(k), self.proxies.keys()))
try:
self.proxies.resolve()
except errors.BatchResolutionFailure as fail:
errors_dict = fail.errors
else:
errors_dict = {}
for key, proxy in self.proxies.items():
# Weird padding hack
# noinspection PyStringFormat
key_text = f"{{key:{key_padding}}}".format(key=key)
if key in errors_dict:
error = errors_dict[key]
if isinstance(error, errors.MissingValueError):
click.secho(f"{key_text} → Required, but not set.", fg="red")
elif isinstance(error, errors.InvalidValueError):
click.secho(f"{key_text}{' '.join(error.args)}", fg="red")
else:
click.secho(f"{key_text}{error!r}", fg="white", bg="bright_red")
else:
value = self.proxies[key]
click.secho(f"{key_text} = {value.__wrapped__!r}", fg="green")
doc = self.docs[key]
doc = textwrap.dedent(doc)
doc = doc.strip("\n")
doc = "\n".join(doc.split("\n")[:3])
click.secho(f"{doc}", fg="white")
click.secho()
return root
def cli(self):
"""
Run the command-line interface.
"""
self._click_root()()
__all__ = ( __all__ = (
"Configuration", "Configuration",

View file

@ -4,11 +4,15 @@ class CfigError(Exception):
""" """
class DefinitionError(CfigError): class DeveloperError(CfigError):
"""
A developer-side error: the user has no way to solve it.
"""
class DefinitionError(DeveloperError):
""" """
An error is present in the definition of a :class:`cfig.Configuration`. An error is present in the definition of a :class:`cfig.Configuration`.
This is a developer-side error: the user has no way to solve it.
""" """
@ -32,11 +36,15 @@ class DuplicateProxyNameError(ProxyRegistrationError):
""" """
class ConfigurationError(CfigError): class UserError(CfigError):
"""
A user-side error: the developer of the application has no way to fix it.
"""
class ConfigurationError(UserError):
""" """
An error is present in the configuration specified by the user. An error is present in the configuration specified by the user.
This is a user-side error: the developer of the application has no way to solve it.
""" """
@ -46,6 +54,23 @@ class MissingValueError(ConfigurationError):
""" """
class InvalidValueError(ConfigurationError):
"""
A configuration key has an invalid value.
This error should be raised by the developer in resolvers if the developer knows that a invalid value has been passed, for example::
@config.required()
def INTEGER(val):
try:
return int(val)
except ValueError:
raise InvalidValueError("Not an int.")
It is not raised automatically, as certain errors might be caused by a mistake in the programming of the resolver.
"""
class BatchResolutionFailure(BaseException): class BatchResolutionFailure(BaseException):
""" """
A cumulative error which sums the errors occurred while resolving proxied configuration values. A cumulative error which sums the errors occurred while resolving proxied configuration values.
@ -58,12 +83,23 @@ class BatchResolutionFailure(BaseException):
return f"<{self.__class__.__qualname__}: {len(self.errors)} errors>" return f"<{self.__class__.__qualname__}: {len(self.errors)} errors>"
class MissingDependencyError(CfigError):
"""
An optional dependency has not been installed, but it is required by a called function.
"""
__all__ = ( __all__ = (
"CfigError",
"DeveloperError",
"DefinitionError", "DefinitionError",
"UnknownResolverNameError", "UnknownResolverNameError",
"ProxyRegistrationError", "ProxyRegistrationError",
"DuplicateProxyNameError", "DuplicateProxyNameError",
"UserError",
"ConfigurationError", "ConfigurationError",
"MissingValueError", "MissingValueError",
"InvalidValueError",
"BatchResolutionFailure", "BatchResolutionFailure",
"MissingDependencyError",
) )

50
cfig/sample/__main__.py Normal file
View file

@ -0,0 +1,50 @@
import cfig
import typing
config = cfig.Configuration()
@config.required()
def MY_FAVOURITE_STRING(val: str) -> str:
"""
Your favourite string!
"""
return val
@config.optional()
def MY_OPTIONAL_STRING(val: typing.Optional[str]) -> str:
"""
Your favourite string, including the empty one!
"""
return val or ""
@config.required()
def MY_REQUIRED_INT(val: str) -> int:
"""
Your favourite integer!
"""
try:
return int(val)
except ValueError:
raise cfig.InvalidValueError("Not an int.")
@config.required()
def MY_FAVOURITE_EVEN_INT(val: str) -> int:
"""
Your favourite even number!
"""
try:
n = int(val)
except ValueError:
raise cfig.InvalidValueError("Not an int.")
if n % 2:
raise cfig.InvalidValueError("Not an even int.")
return n
if __name__ == "__main__":
config.cli()

View file

@ -3,6 +3,12 @@ import cfig
import os import os
import lazy_object_proxy import lazy_object_proxy
try:
import click
import click.testing
except ImportError:
click = None
class TestConfig: class TestConfig:
def test_creation(self): def test_creation(self):
@ -19,7 +25,10 @@ class TestConfig:
@basic_config.required() @basic_config.required()
def FIRST_NUMBER(val: str) -> int: def FIRST_NUMBER(val: str) -> int:
"""The first number to sum.""" """The first number to sum."""
return int(val) try:
return int(val)
except ValueError:
raise cfig.InvalidValueError("Not an int.")
assert isinstance(FIRST_NUMBER, lazy_object_proxy.Proxy) assert isinstance(FIRST_NUMBER, lazy_object_proxy.Proxy)
assert callable(FIRST_NUMBER.__factory__) assert callable(FIRST_NUMBER.__factory__)
@ -31,7 +40,10 @@ class TestConfig:
@basic_config.optional() @basic_config.optional()
def SECOND_NUMBER(val: str) -> int: def SECOND_NUMBER(val: str) -> int:
"""The second number to sum.""" """The second number to sum."""
return int(val) try:
return int(val)
except ValueError:
raise cfig.InvalidValueError("Not an int.")
assert isinstance(SECOND_NUMBER, lazy_object_proxy.Proxy) assert isinstance(SECOND_NUMBER, lazy_object_proxy.Proxy)
assert callable(SECOND_NUMBER.__factory__) assert callable(SECOND_NUMBER.__factory__)
@ -44,12 +56,18 @@ class TestConfig:
@basic_config.required() @basic_config.required()
def FIRST_NUMBER(val: str) -> int: def FIRST_NUMBER(val: str) -> int:
"""The first number to sum.""" """The first number to sum."""
return int(val) try:
return int(val)
except ValueError:
raise cfig.InvalidValueError("Not an int.")
@basic_config.optional() @basic_config.optional()
def SECOND_NUMBER(val: str) -> int: def SECOND_NUMBER(val: str) -> int:
"""The second number to sum.""" """The second number to sum."""
return int(val) try:
return int(val)
except ValueError:
raise cfig.InvalidValueError("Not an int.")
yield basic_config yield basic_config
@ -74,6 +92,27 @@ class TestConfig:
with pytest.raises(cfig.MissingValueError): with pytest.raises(cfig.MissingValueError):
numbers_config.proxies.resolve_failfast() numbers_config.proxies.resolve_failfast()
def test_resolve_invalid(self, numbers_config, monkeypatch):
monkeypatch.setenv("FIRST_NUMBER", "a")
monkeypatch.setenv("SECOND_NUMBER", "")
assert os.environ.get("FIRST_NUMBER", "a")
assert not os.environ.get("SECOND_NUMBER")
with pytest.raises(cfig.BatchResolutionFailure) as ei:
numbers_config.proxies.resolve()
assert isinstance(ei.value.errors["FIRST_NUMBER"], cfig.InvalidValueError)
def test_resolve_ff_invalid(self, numbers_config, monkeypatch):
monkeypatch.setenv("FIRST_NUMBER", "a")
monkeypatch.setenv("SECOND_NUMBER", "")
assert os.environ.get("FIRST_NUMBER", "a")
assert not os.environ.get("SECOND_NUMBER")
with pytest.raises(cfig.InvalidValueError):
numbers_config.proxies.resolve_failfast()
def test_resolve_required(self, numbers_config, monkeypatch): def test_resolve_required(self, numbers_config, monkeypatch):
monkeypatch.setenv("FIRST_NUMBER", "1") monkeypatch.setenv("FIRST_NUMBER", "1")
monkeypatch.setenv("SECOND_NUMBER", "") monkeypatch.setenv("SECOND_NUMBER", "")
@ -207,3 +246,22 @@ class TestConfig:
assert second_number.__resolved__ assert second_number.__resolved__
assert second_number == 4 assert second_number == 4
@pytest.fixture(scope="function")
def click_runner(self):
yield click.testing.CliRunner()
@pytest.mark.skipif(click is None, reason="the `cli` extra is not installed")
def test_cli(self, numbers_config, monkeypatch, click_runner):
monkeypatch.setenv("FIRST_NUMBER", "1")
monkeypatch.setenv("SECOND_NUMBER", "")
assert os.environ.get("FIRST_NUMBER") == "1"
assert not os.environ.get("SECOND_NUMBER")
root = numbers_config._click_root()
result = click_runner.invoke(root, [])
assert result.exit_code == 0
assert "FIRST_NUMBER" in result.output
assert "SECOND_NUMBER" in result.output

2
poetry.lock generated
View file

@ -388,7 +388,7 @@ cli = ["click"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "1a65fa3bfc354d7c11fc399cf8aebff5e3dc15b5e26983fdecfa92a1e209779a" content-hash = "9003e7beceb925ef6b11961ef2a2bb818fbd8f659b312345dd58d86b786e589f"
[metadata.files] [metadata.files]
alabaster = [ alabaster = [

View file

@ -9,9 +9,10 @@ license = "MIT"
python = "^3.10" python = "^3.10"
lazy-object-proxy = "^1.7.1" lazy-object-proxy = "^1.7.1"
click = { version = "^8.1.2", optional = true } click = { version = "^8.1.2", optional = true }
colorama = { version = "^0.4.4", optional = true }
[tool.poetry.extras] [tool.poetry.extras]
cli = ["click"] cli = ["click", "colorama"]
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.1" pytest = "^7.1.1"