mirror of
https://github.com/Steffo99/cfig.git
synced 2024-11-21 15:34:20 +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 logging
|
||||
import collections
|
||||
import textwrap
|
||||
from . import errors
|
||||
from . import customtyping as ct
|
||||
from cfig.sources.base import Source
|
||||
|
@ -274,6 +275,64 @@ class Configuration:
|
|||
log.debug(f"Registering doc {doc!r} in {key!r}")
|
||||
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__ = (
|
||||
"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`.
|
||||
|
||||
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.
|
||||
|
||||
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):
|
||||
"""
|
||||
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>"
|
||||
|
||||
|
||||
class MissingDependencyError(CfigError):
|
||||
"""
|
||||
An optional dependency has not been installed, but it is required by a called function.
|
||||
"""
|
||||
|
||||
|
||||
__all__ = (
|
||||
"CfigError",
|
||||
"DeveloperError",
|
||||
"DefinitionError",
|
||||
"UnknownResolverNameError",
|
||||
"ProxyRegistrationError",
|
||||
"DuplicateProxyNameError",
|
||||
"UserError",
|
||||
"ConfigurationError",
|
||||
"MissingValueError",
|
||||
"InvalidValueError",
|
||||
"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 lazy_object_proxy
|
||||
|
||||
try:
|
||||
import click
|
||||
import click.testing
|
||||
except ImportError:
|
||||
click = None
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_creation(self):
|
||||
|
@ -19,7 +25,10 @@ class TestConfig:
|
|||
@basic_config.required()
|
||||
def FIRST_NUMBER(val: str) -> int:
|
||||
"""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 callable(FIRST_NUMBER.__factory__)
|
||||
|
@ -31,7 +40,10 @@ class TestConfig:
|
|||
@basic_config.optional()
|
||||
def SECOND_NUMBER(val: str) -> int:
|
||||
"""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 callable(SECOND_NUMBER.__factory__)
|
||||
|
@ -44,12 +56,18 @@ class TestConfig:
|
|||
@basic_config.required()
|
||||
def FIRST_NUMBER(val: str) -> int:
|
||||
"""The first number to sum."""
|
||||
return int(val)
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
raise cfig.InvalidValueError("Not an int.")
|
||||
|
||||
@basic_config.optional()
|
||||
def SECOND_NUMBER(val: str) -> int:
|
||||
"""The second number to sum."""
|
||||
return int(val)
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
raise cfig.InvalidValueError("Not an int.")
|
||||
|
||||
yield basic_config
|
||||
|
||||
|
@ -74,6 +92,27 @@ class TestConfig:
|
|||
with pytest.raises(cfig.MissingValueError):
|
||||
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):
|
||||
monkeypatch.setenv("FIRST_NUMBER", "1")
|
||||
monkeypatch.setenv("SECOND_NUMBER", "")
|
||||
|
@ -207,3 +246,22 @@ class TestConfig:
|
|||
|
||||
assert second_number.__resolved__
|
||||
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]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "1a65fa3bfc354d7c11fc399cf8aebff5e3dc15b5e26983fdecfa92a1e209779a"
|
||||
content-hash = "9003e7beceb925ef6b11961ef2a2bb818fbd8f659b312345dd58d86b786e589f"
|
||||
|
||||
[metadata.files]
|
||||
alabaster = [
|
||||
|
|
|
@ -9,9 +9,10 @@ license = "MIT"
|
|||
python = "^3.10"
|
||||
lazy-object-proxy = "^1.7.1"
|
||||
click = { version = "^8.1.2", optional = true }
|
||||
colorama = { version = "^0.4.4", optional = true }
|
||||
|
||||
[tool.poetry.extras]
|
||||
cli = ["click"]
|
||||
cli = ["click", "colorama"]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.1"
|
||||
|
|
Loading…
Reference in a new issue