mirror of
https://github.com/Steffo99/cfig.git
synced 2024-11-22 07:54:21 +00:00
💥 Many things
This commit is contained in:
parent
b41baffedc
commit
302b995867
7 changed files with 241 additions and 12 deletions
25
.idea/runConfigurations/Sample.xml
Normal file
25
.idea/runConfigurations/Sample.xml
Normal 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>
|
|
@ -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",
|
||||||
|
|
|
@ -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
50
cfig/sample/__main__.py
Normal 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()
|
|
@ -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."""
|
||||||
|
try:
|
||||||
return int(val)
|
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."""
|
||||||
|
try:
|
||||||
return int(val)
|
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."""
|
||||||
|
try:
|
||||||
return int(val)
|
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."""
|
||||||
|
try:
|
||||||
return int(val)
|
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
2
poetry.lock
generated
|
@ -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 = [
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue