diff --git a/README.md b/README.md index fa5eaef..dc8bdbc 100644 --- a/README.md +++ b/README.md @@ -4,57 +4,85 @@ ## Features - **Lightweight**: minicfg is a small package with no dependencies. - **Easy to use**: minicfg provides a simple API to define and populate configurations. -- **Type casting**: minicfg supports type casting for the fields. -- **File attachment**: minicfg supports attaching a file to a field. +- **Documentation**: generate documentation for your configuration. +- **Type casting**: minicfg supports type casting for the fields. You can also define your own casters. +- **File field attachment**: minicfg supports attaching a virtual file field to a field. - **Prefixing**: minicfg supports prefixing the fields with a custom name prefix. - **Nested configurations**: minicfg supports nested configurations. - **Custom providers**: minicfg supports custom providers to populate the configuration from different sources. ## Installation Just install minicfg using your favorite package manager, for example: + ```bash pip install minicfg ``` ## Usage + ```python -from minicfg import Minicfg, Field, minicfg_prefix +from minicfg import Minicfg, Field, minicfg_name from minicfg.caster import IntCaster -@minicfg_prefix("MYSERVICE") -class Env(Minicfg): - @minicfg_prefix("BOT") - class TelegramBot(Minicfg): - # attach_file_field=True will read the value from the file provided in MYSERVICE_BOT_TOKEN_FILE env var - # if no file is provided, it will read the value from MYSERVICE_BOT_TOKEN env var. - TOKEN = Field(attach_file_field=True) - - class API1(Minicfg): # <-- API1 class name will be used as a prefix for the fields inside it - API_TOKEN = Field() # API_TOKEN will be read from MYSERVICE_API1_API_TOKEN env var - - @minicfg_prefix("MONGO") - class Mongo(Minicfg): - HOST = Field() - PORT = Field(caster=IntCaster()) # PORT will be casted to an integer type - - -# Populate the configuration from the environment variables: -env = Env.populated() - -print(f"Telegram bot token: {env.TelegramBot.TOKEN}") -print(f"API1 token: {env.API1.API_TOKEN}") -print(f"Mongo settings: {env.Mongo.HOST}:{env.Mongo.PORT}") - -""" + +@minicfg_name("SERVICE") +class MyConfig(Minicfg): + @minicfg_name("DATABASE") + class Database(Minicfg): + HOST = Field(attach_file_field=True, description="database host") + PORT = Field(default=5432, caster=IntCaster(), description="database port") + + @minicfg_name("EXTERNAL_API") + class ExternalAPI(Minicfg): + KEY = Field(description="external API key") + USER_ID = Field(caster=IntCaster(), description="external API user ID") + + +if __name__ == '__main__': + config = MyConfig() # create an instance of the configuration + config.populate() # populate the configuration from the environment variables + + print(f"connect to database at {config.Database.HOST}:{config.Database.PORT}") + print(f"external API key: {config.ExternalAPI.KEY}") + print(f"external API user: {config.ExternalAPI.USER_ID}") +``` + Try running the script with the following environment variables: -MYSERVICE_BOT_TOKEN=token MYSERVICE_API1_API_TOKEN=token123 MYSERVICE_MONGO_HOST=localhost MYSERVICE_MONGO_PORT=5432 +- `SERVICE_DATABASE_HOST=example.com` +- `SERVICE_DATABASE_PORT=5432` +- `SERVICE_EXTERNAL_API_KEY=token` +- `SERVICE_EXTERNAL_API_USER_ID=123` + And you should see the following output: ->>> Telegram bot token: token ->>> API1 token: token123 ->>> Mongo settings: localhost:5432 -""" +``` +connect to database at example.com:5432 +external API key: token +external API user: 123 ``` > More examples are available [here](/examples). +### Documentation generation +You can use `minicfg` script to generate documentation for your configuration. + +For example, `minicifg --format=markdown example.MyConfig` will generate documentation for +the `MyConfig` class above, and it would look like this: + +**SERVICE** +| Name | Type | Default | Description | +| ---- | ---- | ------- | ----------- | + +**SERVICE_DATABASE** +| Name | Type | Default | Description | +| ---------------------------- | ----- | ------- | ------------------ | +| `SERVICE_DATABASE_HOST` | `str` | N/A | database host | +| `SERVICE_DATABASE_HOST_FILE` | `str` | N/A | database host file | +| `SERVICE_DATABASE_PORT` | `int` | `5432` | database port | + + +**SERVICE_EXTERNAL_API** +| Name | Type | Default | Description | +| ------------------------------ | ----- | ------- | -------------------- | +| `SERVICE_EXTERNAL_API_KEY` | `str` | N/A | external API key | +| `SERVICE_EXTERNAL_API_USER_ID` | `int` | N/A | external API user ID | diff --git a/examples/attach_file_field.py b/examples/attach_file_field.py deleted file mode 100644 index b2ca3cd..0000000 --- a/examples/attach_file_field.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -This example demonstrates how to attach a file field to another field. -""" - -from minicfg import Field, Minicfg, minicfg_prefix -from minicfg.provider import AbstractProvider - - -class MyProvider(AbstractProvider): - """ - A provider that reads the hostname from the /etc/hostname file. - """ - - data = { - "DATABASE_HOST_FILE": "/etc/hostname", - } - - def get(self, key: str) -> str | None: - return self.data.get(key) - - -class MyConfig(Minicfg): - @minicfg_prefix("DATABASE") - class Database(Minicfg): - """ - A virtual DATABASE_HOST_FILE file field will be also created and attached to - the DATABASE_HOST field below. - If DATABASE_HOST_FILE is provided, the value of DATABASE_HOST will be read from the file. - """ - - HOST = Field(attach_file_field=True) - - -provider = MyProvider() -config = MyConfig.populated(provider) - -print(f"{config.Database.HOST=}") -# >>> config.Database.HOST='' diff --git a/examples/docs.py b/examples/docs.py new file mode 100644 index 0000000..5b235f6 --- /dev/null +++ b/examples/docs.py @@ -0,0 +1,49 @@ +""" +This example demonstrates how to document the configuration class. +""" + +from minicfg import Field, Minicfg, minicfg_name +from minicfg.caster import IntCaster +from minicfg.docs_generator import DocsGenerator +from minicfg.provider import AbstractProvider + + +class MockProvider(AbstractProvider): + """ + A custom mock provider. + Used to simulate the environment variables. + """ + + data = {"DATABASE_HOST": "example.com", "DATABASE_PORT": "5432", "EXTERNAL_API_KEY": "api_key"} + + def get(self, key: str) -> str | None: + return self.data.get(key) + + +class MyConfig(Minicfg): + @minicfg_name("DATABASE") + class Database(Minicfg): + HOST = Field(default="localhost", description="database host") + PORT = Field(caster=IntCaster(), description="database port") + + @minicfg_name("EXTERNAL_API") + class ExternalAPI(Minicfg): + KEY = Field(description="external API key") + + +""" +Try running `python docs.py` or `python -m minicfg docs.MyConfig --format=plaintext` and you should see the following output: +MyConfig + +DATABASE + - DATABASE_HOST: str = localhost # database host + - DATABASE_PORT: int # database port + + +EXTERNAL_API + - EXTERNAL_API_KEY: str # external API key +""" +if __name__ == "__main__": + config = MyConfig() # create a new instance of the config + docs_generator = DocsGenerator(config) # create a new instance of the docs generator + print(docs_generator.as_plaintext()) # print the documentation as plain text diff --git a/examples/file_field.py b/examples/file_field.py new file mode 100644 index 0000000..4ce06fd --- /dev/null +++ b/examples/file_field.py @@ -0,0 +1,51 @@ +""" +This example demonstrates how to attach a file field to another field. +""" + +from minicfg import Field, Minicfg +from minicfg.provider import AbstractProvider + + +class MyProvider(AbstractProvider): + """ + A provider that reads the hostname from the /etc/hostname file. + """ + + data = { + "DATABASE_HOST_FILE": "/etc/hostname", + } + + def get(self, key: str) -> str | None: + return self.data.get(key) + + +class MyConfig(Minicfg): + """ + My configuration class. + """ + + """ + A virtual DATABASE_HOST_FILE field will be also created and attached to the DATABASE_HOST field. + + - If only DATABASE_HOST_FILE is provided, the field value will be read from the file. + - If only DATABASE_HOST is provided, the field value will be used directly from it. + - If both DATABASE_HOST_FILE and DATABASE_HOST are provided, the value of DATABASE_HOST will be used. + - If none of them are provided, the FieldValueNotProvidedError will be raised. + """ + DATABASE_HOST = Field(attach_file_field=True) + + +""" +Try running `python file_field.py` and you should see the following output: +>>> config.DATABASE_HOST='' + +If you don't have a hostname file, the FileNotFound exception will be raised. +""" +if __name__ == "__main__": + provider = MyProvider() # create a new instance of the custom provider + + config = MyConfig() # create a new instance of the config + config.populate(provider) # populate the config using the custom provider + + print(f"{config.DATABASE_HOST=}") + # >>> config.DATABASE_HOST='' diff --git a/examples/nesting.py b/examples/nesting.py new file mode 100644 index 0000000..e5b4e64 --- /dev/null +++ b/examples/nesting.py @@ -0,0 +1,59 @@ +""" +This example demonstrates how to use prefixes with minicfg. +""" + +from minicfg import Field, Minicfg, minicfg_name +from minicfg.caster import IntCaster +from minicfg.provider import AbstractProvider + + +class MockProvider(AbstractProvider): + """ + A custom mock provider. + Used to simulate the environment variables. + """ + + data = { + "SERVICE_DATABASE_HOST": "example.com", + "SERVICE_EXTERNAL_API_KEY": "api_key", + "SERVICE_EXTERNAL_API_USER_ID": "user123", + } + + def get(self, key: str) -> str | None: + return self.data.get(key) + + +@minicfg_name("SERVICE") # <-- The prefix for the main config (will be inherited by nested configs). +class MyConfig(Minicfg): + """ + My configuration class. + """ + + @minicfg_name("DATABASE") # <-- The prefix for the nested config. + class Database(Minicfg): + HOST: str = Field() + PORT: int = Field(default=5432, caster=IntCaster()) + + @minicfg_name("EXTERNAL_API") # <-- The prefix for the nested config. + class ExternalAPI(Minicfg): + KEY: str = Field(description="external API key") + USER_ID: str = Field(description="external API user ID") + + +""" +Try running `python nesting.py` and you should see the following output: +>>> config.Database.HOST='example.com' +>>> config.Database.PORT=5432 +>>> config.ExternalAPI.KEY='api_key' +>>> config.ExternalAPI.USER_ID='user123' +""" +if __name__ == "__main__": + provider = MockProvider() # create a new instance of the custom provider + + config = MyConfig() # create a new instance of the config + config.populate(provider) # populate the config using the custom provider + + print(f"{config.Database.HOST=}") + print(f"{config.Database.PORT=}") + print(f"{config.ExternalAPI.KEY=}") + print(f"{config.ExternalAPI.USER_ID=}") diff --git a/examples/prefixes.py b/examples/prefixes.py deleted file mode 100644 index a0c747c..0000000 --- a/examples/prefixes.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -This example demonstrates how to use prefixes with minicfg. -""" - -from minicfg import Field, Minicfg, minicfg_prefix -from minicfg.caster import IntCaster - - -@minicfg_prefix("SERVICE") # <-- The prefix for the main config. -class Env(Minicfg): - """ - The main config with the "SERVICE_" prefix. - """ - - # Sub minicfg without any prefix indicated. - # prefix will be inherited from the parent minicfg and prepended to the class name. - # The full prefix: "SERVICE_Meta_". - class Meta(Minicfg): - SOME_VAR: str = Field() - SOME_OTHER_VAR: str = Field(default="this is default value") - - # Sub minicfg with "BOT" prefix indicated. - # The full prefix: "SERVICE_BOT_". - @minicfg_prefix("BOT") # <-- The prefix for the sub config. - class Bot(Minicfg): - TOKEN: str = Field() - - # Sub minicfg with "MONGO" prefix indicated. - # The full prefix: "SERVICE_MONGO_". - @minicfg_prefix("MONGO") # <-- The prefix for the sub config. - class Mongo(Minicfg): - HOST: str = Field() - PORT: int = Field(caster=IntCaster()) # PORT will be cast to int - - -env = Env.populated() # populate the config using env vars (by default) - -print("Meta:") -print(f"{env.Meta.SOME_VAR=}") -print(f"{env.Meta.SOME_OTHER_VAR=}") -print() - -print("Bot:") -print(f"{env.Bot.TOKEN=}") -print() - -print("Mongo:") -print(f"{env.Mongo.HOST=}") -print(f"{env.Mongo.PORT=}") - -""" -Try running `SERVICE_Meta_SOME_VAR=xyz SERVICE_BOT_TOKEN=token SERVICE_MONGO_HOST=localhost SERVICE_MONGO_PORT=12 python prefixes.py` and you should see the following output: - ->>> Meta: ->>> env.Meta.SOME_VAR='xyz' ->>> env.Meta.SOME_OTHER_VAR='this is default value' - ->>> Bot: ->>> env.Bot.TOKEN='token' - ->>> Mongo: ->>> env.Mongo.HOST='localhost' ->>> env.Mongo.PORT=12 -""" diff --git a/examples/provider.py b/examples/provider.py index d3a6923..8a2874b 100644 --- a/examples/provider.py +++ b/examples/provider.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use a custom provider to populate the configuration. +This example demonstrates how to use a custom provider to populate the configuration class. """ from minicfg import Field, Minicfg @@ -9,33 +9,34 @@ class MockProvider(AbstractProvider): """ - A custom mock provider that provides the DATABASE_HOST value. + A custom mock provider. """ - def __init__(self): - self.data = { - "DATABASE_HOST": "localhost", - } + data = {"DATABASE_HOST": "example.com", "DATABASE_PORT": "5432"} def get(self, key: str) -> str | None: return self.data.get(key) -class MockConfig(Minicfg): - DATABASE_HOST: str = Field() - DATABASE_PORT: int = Field(default=1234, caster=IntCaster()) - - -mock_provider = MockProvider() -config = MockConfig.populated(mock_provider) +class MyConfig(Minicfg): + """ + My configuration class. + """ -print(f"{config.DATABASE_HOST=}") -print(f"{config.DATABASE_PORT=}") + DATABASE_HOST: str = Field() + DATABASE_PORT: int = Field(caster=IntCaster()) """ Try running `python provider.py` and you should see the following output: - >>> config.DATABASE_HOST='localhost' ->>> config.DATABASE_PORT=1234 +>>> config.DATABASE_PORT=5432 """ +if __name__ == "__main__": + mock_provider = MockProvider() # create a new instance of the custom provider + + config = MyConfig() # create a new instance of the config + config.populate(mock_provider) # populate the config using the custom provider + + print(f"{config.DATABASE_HOST=}") + print(f"{config.DATABASE_PORT=}") diff --git a/examples/simple.py b/examples/simple.py index 4ffcaa6..fc69648 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -6,7 +6,7 @@ from minicfg.caster import IntCaster -class Env(Minicfg): +class MyConfig(Minicfg): """ The main config. """ @@ -15,14 +15,7 @@ class Env(Minicfg): DATABASE_HOST: str = Field(default="localhost") # DATABASE_PORT value will be cast to int - DATABASE_PORT: int = Field(caster=IntCaster()) - - -# Populate the config using env vars (by default): -env = Env.populated() - -print(f"{env.DATABASE_HOST=}") -print(f"{env.DATABASE_PORT=}") + DATABASE_PORT: int = Field(default=123, caster=IntCaster()) """ @@ -36,3 +29,9 @@ class Env(Minicfg): >>> env.DATABASE_HOST='localhost' # default value is used >>> env.DATABASE_PORT=5432 """ +if __name__ == "__main__": + env = MyConfig() # create a new instance of the config + env.populate() # populate the config using env vars (by default) + + print(f"{env.DATABASE_HOST=}") + print(f"{env.DATABASE_PORT=}") diff --git a/minicfg/__init__.py b/minicfg/__init__.py index d810eb6..51e544b 100644 --- a/minicfg/__init__.py +++ b/minicfg/__init__.py @@ -1,2 +1,2 @@ from .field import Field -from .minicfg import Minicfg, minicfg_prefix, minicfg_raw_prefix +from .minicfg import Minicfg, minicfg_name diff --git a/minicfg/__main__.py b/minicfg/__main__.py new file mode 100644 index 0000000..9d442cb --- /dev/null +++ b/minicfg/__main__.py @@ -0,0 +1,64 @@ +""" +This simple tool will help you generating documentation for your minicfg classes. + +Usage: minicfg [--format ] +Example: minicfg --format plaintext my_package.my_module.MyConfig +""" + +import argparse +import importlib +from enum import Enum + +from minicfg.docs_generator import DocsGenerator + + +class _Format(Enum): + """ + Output format enum. + """ + + PLAINTEXT = "plaintext" + MARKDOWN = "markdown" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + "minicfg", description="This simple tool will help you generating documentation for your minicfg classes." + ) + parser.add_argument("path", type=str, help="Path to the minicfg class (e.g. my_package.my_module.MyConfig)") + parser.add_argument( + "--format", + "-f", + type=str, + choices=[f.value for f in _Format], + default=_Format.MARKDOWN.value, + help="Output format (plaintext or markdown)", + ) + + return parser.parse_args() + + +def main(): + args = _parse_args() + + module_name, class_name = args.path.rsplit(".", 1) + module = importlib.import_module(module_name) + + minicfg_class = getattr(module, class_name) + minicfg_instance = minicfg_class() + + docs_generator = DocsGenerator(minicfg_instance) + docs: str + match args.format: + case _Format.PLAINTEXT.value: + docs = docs_generator.as_plaintext() + case _Format.MARKDOWN.value: + docs = docs_generator.as_markdown() + case _: + raise ValueError(f"unexpected format {args.format}") + + print(docs) + + +if __name__ == "__main__": + main() diff --git a/minicfg/caster.py b/minicfg/caster.py index 00eac57..ab4cfb7 100644 --- a/minicfg/caster.py +++ b/minicfg/caster.py @@ -1,5 +1,6 @@ import typing from abc import ABC, abstractmethod +from collections.abc import Callable class AbstractCaster(ABC): @@ -7,12 +8,20 @@ class AbstractCaster(ABC): Abstract caster class. """ + @abstractmethod + def typename(self) -> str | None: + """ + Get the type name of the caster. + For example, "int", "float", "bool", etc. None if the type name is not available. + """ + pass + @abstractmethod def cast(self, value: str) -> typing.Any: """ Cast the provided value to the desired type. - :param value: value to be casted. - :return: the casted value. + :param value: value to be cast. + :return: the cast value. """ pass @@ -23,6 +32,10 @@ class IntCaster(AbstractCaster): Caster that casts the provided value to an integer. """ + @property + def typename(self) -> str: + return "int" + def cast(self, value: str) -> int: return int(value) @@ -32,6 +45,10 @@ class FloatCaster(AbstractCaster): Caster that casts the provided value to a float. """ + @property + def typename(self) -> str: + return "float" + def cast(self, value: str) -> float: return float(value) @@ -41,11 +58,12 @@ class BoolCaster(AbstractCaster): Caster that casts the provided value to a boolean. """ - true = {"true", "yes", "on", "enable", "1"} - false = {"false", "no", "off", "disable", "0"} + true = {"true", "yes", "on", "enable", "enabled", "1"} + false = {"false", "no", "off", "disable", "disabled", "0"} - def __init__(self): - pass + @property + def typename(self) -> str: + return "bool" def cast(self, value: str) -> bool: if value in self.true: @@ -53,7 +71,7 @@ def cast(self, value: str) -> bool: elif value in self.false: return False - raise ValueError("the provided value cannot be casted to bool") + raise ValueError("the provided value cannot be cast to bool") class ListCaster(AbstractCaster): @@ -71,19 +89,54 @@ def __init__(self, sep: str = ",", item_caster: AbstractCaster | None = None): self.sep = sep self.item_caster = item_caster + @property + def typename(self) -> str: + if self.item_caster: + if self.item_caster.typename: + return f"list[{self.item_caster.typename}]" + else: + return "list" + return "list[str]" + def cast(self, value: str) -> list[typing.Any]: str_items = value.split(self.sep) if self.item_caster: - casted_items: list[typing.Any] = [] + cast_items: list[typing.Any] = [] for item in str_items: try: - casted = self.item_caster.cast(item) - casted_items.append(casted) + cast_items.append(self.item_caster.cast(item)) except Exception as e: raise ValueError( - f'failed to cast list item "{item}" using {self.item_caster.__class__.__name__}' + f'failed to cast list item "{item}" using item caster {self.item_caster.__class__.__name__}' ) from e - return casted_items + return cast_items return str_items + + +class JSONCaster(AbstractCaster): + """ + Caster that casts the provided value to a JSON object. + """ + + _load: Callable[[str], dict[typing.Any, typing.Any]] | None + + def __init__(self, load: Callable[[str], dict[typing.Any, typing.Any]] | None = None): + """ + Initialize JSONCaster. + :param load: custom json load function. If set to None, standard json.load is used. + """ + self._load = load + + @property + def typename(self) -> str: + return "json" + + def cast(self, value: str) -> dict[typing.Any, typing.Any] | list[typing.Any]: + if self._load: + return self._load(value) + + import json + + return json.loads(value) diff --git a/minicfg/docs_generator.py b/minicfg/docs_generator.py new file mode 100644 index 0000000..22714cf --- /dev/null +++ b/minicfg/docs_generator.py @@ -0,0 +1,117 @@ +import dataclasses + +from minicfg import Field, Minicfg +from minicfg.field import NO_DEFAULT_VALUE + + +def _generate_markdown_table(headers: list[str], data: list[list[str]]) -> str: + """ + Generate a Markdown table from the given headers and data. + :param headers: list of header strings. + :param data: table data. + :return: Markdown table string. + """ + + # Determine the maximum width for each column + column_widths = [max(len(str(item)) for item in column) for column in zip(*([headers] + data))] + + # Create the header row with padding + header_row = "| " + " | ".join(f"{header:<{column_widths[i]}}" for i, header in enumerate(headers)) + " |" + separator_row = "| " + " | ".join("-" * width for width in column_widths) + " |" + + # Add data rows with padding + data_rows = "" + for row in data: + data_rows += "\n| " + " | ".join(f"{str(item):<{column_widths[i]}}" for i, item in enumerate(row)) + " |" + + # Combine header, separator, and data rows + return header_row + "\n" + separator_row + data_rows + + +@dataclasses.dataclass +class FieldMeta: + """ + FieldMeta class represents metadata about a field. + """ + + name: str + type: str | None # None means that the type is not specified + default: str | None # None means that the default value is not specified + description: str | None + + @classmethod + def from_field(cls, field: Field) -> "FieldMeta": + return cls( + name=field.name, + type=field.caster.typename if field.caster else "str", + default=str(field.default) if field.default is not NO_DEFAULT_VALUE else None, + description=field.description, + ) + + +class DocsGenerator: + """ + DocsGenerator class generates documentation for the Minicfg instance + """ + + _config_name: str + _fields: list[FieldMeta] + _child_generators: list["DocsGenerator"] + + def __init__(self, config: Minicfg): + self._config_name = config.name or config.__class__.__name__ + self._fields = [] + self._child_generators = [] + + for child in config: + if isinstance(child, Field): + self._fields.append(FieldMeta.from_field(child)) + if child.file_field: + self._fields.append(FieldMeta.from_field(child.file_field)) + elif isinstance(child, Minicfg): + self._child_generators.append(DocsGenerator(child)) + else: + raise ValueError(f"unexpected child type: {type(child)}") + + def as_plaintext(self) -> str: + """ + Generate a plaintext documentation for the Minicfg instance. + :return: plaintext documentation string. + """ + + result = f"{self._config_name}\n" + for field in self._fields: + result += f" - {field.name}" + if field.type: + result += f": {field.type}" + if field.default: + result += f" = {field.default}" + if field.description: + result += f" # {field.description}" + + result += "\n" + + child_plaintexts = "\n".join(child.as_plaintext() for child in self._child_generators) + return f"{result}\n{child_plaintexts}" + + def as_markdown(self) -> str: + """ + Generate a Markdown documentation for the Minicfg instance. + :return: Markdown documentation string. + """ + + table_data: list[list[str]] = [] + for field in self._fields: + table_data.append( + [ + f"`{field.name}`", + f"`{field.type}`" if field.type else "N/A", + f"`{field.default}`" if field.default else "N/A", + field.description or "", + ] + ) + + table = _generate_markdown_table(["Name", "Type", "Default", "Description"], table_data) + child_markdowns = "\n".join(child.as_markdown() for child in self._child_generators) + + return f"**{self._config_name}**\n{table}\n\n{child_markdowns}" diff --git a/minicfg/exceptions.py b/minicfg/exceptions.py deleted file mode 100644 index f35b336..0000000 --- a/minicfg/exceptions.py +++ /dev/null @@ -1,84 +0,0 @@ -from .caster import AbstractCaster -from .provider import AbstractProvider - - -class CastingError(Exception): - """ - Exception raised when an error occurs during casting. - """ - - field_name: str - raw_value: str - caster: AbstractCaster - exception: Exception - - def __init__( - self, - field_name: str, - raw_value: str, - caster: AbstractCaster, - exception: Exception, - file_field_name: str | None = None, - file_field_value: str | None = None, - ): - self.field_name = field_name - self.raw_value = raw_value - self.caster = caster - self.exception = exception - - caster_name = caster.__class__.__name__ - - if file_field_name: - msg = ( - f'exception occurred when casting {field_name} raw value "{raw_value}" ' - f"using {caster_name}: {exception} (the raw value was read from {file_field_value}, which was provided by the attached file field {file_field_name})" - ) - else: - msg = ( - f'exception occurred when casting {field_name} raw value "{raw_value}" ' - f"using {caster_name}: {exception}" - ) - super().__init__(msg) - - -class FieldValueNotProvidedError(Exception): - """ - Exception raised when a field value is not provided by the provider. - """ - - field_name: str - provider: AbstractProvider - file_field_name: str | None - - def __init__(self, field_name: str, provider: AbstractProvider, file_field_name: str | None = None): - self.field_name = field_name - self.provider = provider - self.file_field_name = file_field_name - - provider_name = provider.__class__.__name__ - if file_field_name is not None: - msg = f"neither {field_name} nor {file_field_name} were provided by {provider_name}, at least one was expected" - else: - msg = f"{field_name} was not provided by {provider_name}, but was expected" - - super().__init__(msg) - - -class FieldConflictError(Exception): - """ - Exception raised when a conflict occurs between a field and its corresponding file field. - """ - - field_name: str - file_field_name: str - - def __init__(self, field_name: str, file_field_name: str, provider: AbstractProvider): - self.field_name = field_name - self.file_field_name = file_field_name - - provider_name = provider.__class__.__name__ - super().__init__( - f"values for both {field_name} and {file_field_name} " - f"fields were provided by {provider_name}, " - "expected only value for one of them" - ) diff --git a/minicfg/field.py b/minicfg/field.py index aaba0c3..493b2dc 100644 --- a/minicfg/field.py +++ b/minicfg/field.py @@ -1,21 +1,37 @@ from typing import Any from .caster import AbstractCaster -from .exceptions import CastingError, FieldConflictError, FieldValueNotProvidedError from .provider import AbstractProvider -_NOT_SET = object() +class CastingError(Exception): + """ + Exception raised when an error occurs during casting. + """ -def _read_raw_value_from_file(path: str) -> str: + def __init__( + self, + field_name: str, + raw_value: str, + caster: AbstractCaster, + ): + super().__init__( + f'failed to cast raw value "{raw_value}" of the field {field_name} using {caster.__class__.__name__}', + ) + + +class FieldValueNotProvidedError(Exception): """ - Read the raw value from the file at the given path. - :param path: path to the file. - :return: the raw value read from the file (with leading and trailing whitespaces removed). + Exception raised when a field value is not provided by the provider. """ - with open(path, "r") as file: - return file.read().strip() + def __init__(self, field_name: str, provider: AbstractProvider): + super().__init__( + f"{field_name} was not provided by {provider.__class__.__name__}, but was expected", + ) + + +NO_DEFAULT_VALUE = object() class Field: @@ -27,24 +43,35 @@ class Field: _default: Any # default value of the field _caster: AbstractCaster # caster used to cast raw values - _attach_file_field: bool # indicates whether file field should be attached to the field + _description: str # description of the field in documentation purposes + _file_field: "Field | None" # file field attached to the field - _populated_value: Any # value determined after field population + _value: Any # value determined after field population def __init__( self, name: str | None = None, - default: Any = _NOT_SET, + default: Any = NO_DEFAULT_VALUE, caster: AbstractCaster | None = None, + description: str | None = None, attach_file_field: bool = False, ): - self._name = name + """ + Initialize the field. + :param name: name of the field. If not set, the name will be determined by the attribute name. + :param default: default value of the field. + :param caster: caster used to cast raw value to field value. + :param description: description of the field in documentation purposes. + :param attach_file_field: indicates whether file field should be attached to the field + """ + self._name = name self._default = default self._caster = caster - self._attach_file_field = attach_file_field + self._description = description + self._file_field = Field(name=f"{self._name}_FILE" if self._name else None, description=f"{self._description} file" if self._description else None) if attach_file_field else None - self._populated_value = _NOT_SET + self._value = None @property def name(self) -> str | None: @@ -55,73 +82,92 @@ def name(self, value: str) -> None: self._name = value @property - def populated_value(self) -> Any: - return self._populated_value + def default(self) -> Any: + """ + Return the default value of the field. + """ + + return self._default - def populate(self, provider: AbstractProvider, field_name_prefix: str | None = None) -> None: + @property + def caster(self) -> AbstractCaster: """ - Populate the field using the given provider. - :param provider: provider used to get the field raw value. - :param field_name_prefix: prefix to prepend to the field name. + Return the caster used to cast raw values to field """ - if field_name_prefix is None: - field_name_prefix = "" + return self._caster - field_name = f"{field_name_prefix}{self._name}" # name of the field + @property + def description(self) -> str: + """ + Return the field description. + """ - raw_value_from_provider: str | None = provider.get(field_name) - if raw_value_from_provider is not None: - if self._attach_file_field: - file_field_name = f"{field_name_prefix}{self._name}_FILE" # name of the corresponding file field - if provider.get(file_field_name) is not None: - raise FieldConflictError(field_name, file_field_name, provider) + return self._description - try: - self._populated_value = self._cast_raw_value_if_needed(raw_value_from_provider) + @property + def value(self) -> Any: + """ + Return the value of the field. + """ + + return self._value + + @property + def file_field(self) -> "Field | None": + """ + Return the attached file field. + """ + + return self._file_field + + def populate(self, provider: AbstractProvider) -> None: + """ + Populate the field using the given provider. + :param provider: provider to use to get the raw value of the field. + """ + + raw_value: str | None = provider.get(self._name) + if raw_value is None: + if self._file_field: + # populate field using attached file field + try: + self._file_field.populate(provider) + except FieldValueNotProvidedError as e: + if self._default is not NO_DEFAULT_VALUE: + # use the default value if it is provided + self._value = self._default + return + raise FieldValueNotProvidedError(field_name=self._name, provider=provider) from e + raw_value = _read_raw_value_from_file(self._file_field.value) + elif self._default is not NO_DEFAULT_VALUE: + # use the default value if it is provided + self._value = self._default return + else: + # raise an error if the value is not provided and no default value is set + raise FieldValueNotProvidedError(field_name=self._name, provider=provider) + + populated_value: Any = raw_value + if self.caster: + try: + populated_value = self.caster.cast(raw_value) except Exception as e: raise CastingError( - field_name=field_name, raw_value=raw_value_from_provider, caster=self._caster, exception=e + field_name=self._name, + raw_value=raw_value, + caster=self._caster, ) from e - if self._attach_file_field: - file_field_name = f"{field_name_prefix}{self._name}_FILE" # name of the corresponding file field - filepath = provider.get(file_field_name) - if filepath is not None: - raw_value_from_file = _read_raw_value_from_file(filepath) - try: - self._populated_value = self._cast_raw_value_if_needed(raw_value_from_file) - return - except Exception as e: - raise CastingError( - field_name=field_name, - raw_value=raw_value_from_file, - caster=self._caster, - exception=e, - file_field_name=file_field_name, - file_field_value=filepath, - ) from e - - if self._default is not _NOT_SET: - self._populated_value = self._default - return - - raise FieldValueNotProvidedError(field_name, provider, file_field_name) + self._value = populated_value - if self._default is not _NOT_SET: - self._populated_value = self._default - return - raise FieldValueNotProvidedError(field_name, provider) +def _read_raw_value_from_file(path: str) -> str: + """ + Read the raw value from the file at the given path. + :param path: path to the file. + :return: the raw value read from the file (with leading and trailing whitespaces removed). + """ - def _cast_raw_value_if_needed(self, raw_value: str) -> Any: - """ - Cast the raw value using the caster if it is set. - If no caster is set, return the raw value as is. - :param raw_value: the raw value to be cast. - :return: the casted value. - """ - if self._caster: - return self._caster.cast(raw_value) - return raw_value + with open(path, "r") as file: + return file.read().strip() diff --git a/minicfg/minicfg.py b/minicfg/minicfg.py index aad4689..94104cf 100644 --- a/minicfg/minicfg.py +++ b/minicfg/minicfg.py @@ -4,6 +4,7 @@ from .provider import AbstractProvider, EnvProvider _DEFAULT_PROVIDER = EnvProvider +_DEFAULT_NAME_SEP = "_" class Minicfg: @@ -11,18 +12,56 @@ class Minicfg: Base class for configuration classes. """ - _prefix: str # prefix used for all fields in the Minicfg instance + """ + Name of the minicfg. None value means that the minicfg has no name. + """ + _name: str | None = None + + """ + Separator used to separate the minicfg name (if any) and the field name. + """ + _name_sep: str = _DEFAULT_NAME_SEP def __init__(self): - if not hasattr(self, "_prefix"): - # if prefix is not set, set it to an empty string: - self._prefix = "" + """ + Initialize the Minicfg instance. + Generates names for all fields and initialize child minicfgs. + """ + + # add the minicfg name prefix to all field names: + for attr_name, field in self._iter_field_instances(): + if field.name is None: + # use the attribute name as the field name if field name is not set: + field.name = attr_name + + if self._name: + # prepend the minicfg name to the field name the minicfg has a name: + field.name = f"{self._name}{self._name_sep}{field.name}" + + if field.file_field: + # update the field's attached file field name: + field.file_field.name = f"{field.name}_FILE" + + # initialize the child minicfgs: + for attr_name in dir(self.__class__): + attr_value = getattr(self.__class__, attr_name) + if not (isinstance(attr_value, type) and issubclass(attr_value, Minicfg)): + continue + + child_minicfg_class = attr_value + if self._name: + # prepend the minicfg name to the child minicfg name if the minicfg has a name: + if child_minicfg_class._name: + child_minicfg_class._name = f"{self._name}{self._name_sep}{child_minicfg_class._name}" + else: + child_minicfg_class._name = self._name + + setattr(self, attr_name, child_minicfg_class()) @classmethod - def populated(cls, provider: AbstractProvider | None = None) -> "Minicfg": + def new_populated(cls, provider: AbstractProvider | None = None) -> "Minicfg": """ Create an instance of the Minicfg class and populate it with the given provider. - :param provider: provider used to populate the Minicfg instance. :return: populated Minicfg instance. """ @@ -32,12 +71,11 @@ def populated(cls, provider: AbstractProvider | None = None) -> "Minicfg": return minicfg @property - def prefix(self) -> str: - return self._prefix - - @prefix.setter - def prefix(self, value: str): - self._prefix = value + def name(self): + """ + Name of the minicfg. + """ + return self._name def populate(self, provider: AbstractProvider | None = None) -> None: """ @@ -50,77 +88,69 @@ def populate(self, provider: AbstractProvider | None = None) -> None: if not provider: provider = _DEFAULT_PROVIDER() - for attr_name, attr in self._iter_public_attrs(): - if isinstance(attr, type) and issubclass(attr, Minicfg): # if attribute is a child Minicfg type - # create an instance of the child minicfg: - child_minicfg = attr() - - # use class name as a child minicfg prefix if it is not set: - if child_minicfg.prefix == "": - child_minicfg.prefix = f"{child_minicfg.__class__.__name__}_" - - # prepend prefix from the parent minicfg instance: - child_minicfg.prefix = f"{self._prefix}{child_minicfg._prefix}" - - # populate the child minicfg: - child_minicfg.populate(provider) - - # replace the child minicfg class with its instance: - self.__setattr__(attr_name, child_minicfg) - - elif isinstance(attr, Field): # if attribute is an instance of a Field - field = attr + # populate all fields: + for attr_name, field in self._iter_field_instances(): + field.populate(provider) + setattr( + self, attr_name, field.value + ) # replace the field attribute with the populated value. Original Field instances will be accessible only in self.__class__ - # if field name is not set, set it to the attribute name: - if not field.name: - field.name = attr_name + # populate all child minicfgs: + for child_minicfg in self._iter_minicfg_instances(): + child_minicfg.populate(provider) - # populate field: - field.populate(provider, self._prefix) + def _iter_field_instances(self) -> typing.Generator[typing.Tuple[str, Field], None, None]: + """ + Iterate over all field instances. + """ - # replace the field with its populated value: - self.__setattr__(attr_name, field.populated_value) + # (using self.__class__ to access the original Field instances even if minicfg is populated) + for attr_name in dir(self.__class__): + attr_value = getattr(self.__class__, attr_name) + if isinstance(attr_value, Field): + yield attr_name, attr_value - def _iter_public_attrs(self) -> typing.Generator[tuple[str, typing.Any], None, None]: + def _iter_minicfg_instances(self) -> typing.Generator["Minicfg", None, None]: """ - Iterate over public attributes of the Minicfg instance. - - :return: generator of tuples (attribute name, attribute). + Iterate over all child minicfg instances. """ for attr_name in dir(self): - if attr_name.startswith("_"): - continue + attr_value = getattr(self, attr_name) + if isinstance(attr_value, Minicfg): + yield attr_value - attr = super().__getattribute__(attr_name) - yield attr_name, attr + def __iter__(self): + """ + Iterate over all fields and child minicfg instances. + """ + for _, field in self._iter_field_instances(): + yield field + for minicfg in self._iter_minicfg_instances(): + yield minicfg -def minicfg_prefix(prefix: str): +def minicfg_name(name: str): """ - Decorator for setting a prefix for the Minicfg class. - Will be used as a prefix for all fields in the Minicfg class. - - Please note, that an "_" will be appended to the prefix. Use raw_prefix instead if you don't want it. - :param prefix: prefix. + Decorator used to set the name of the mincfg. + :param name: name of the minicfg. """ def decorator(cls: Minicfg): - cls._prefix = f"{prefix}_" + cls._name = name return cls return decorator -def minicfg_raw_prefix(prefix: str): +def minicfg_name_sep(sep: str): """ - Decorator for setting a prefix for the Minicfg class. - Will be used as a prefix for all fields in the Minicfg class. - :param prefix: prefix. + Decorator used to set the separator of the mincfg. + :param sep: separator of the minicfg. """ def decorator(cls: Minicfg): - cls._prefix = prefix + cls._name_sep = sep return cls return decorator diff --git a/pdm.lock b/pdm.lock index 417ace0..f37a90c 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,15 +5,15 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:d962984d6bf38178173d12ebbf1e377817b4bc815bfb0a141f4b74ea609ce8ca" +content_hash = "sha256:fc5ff20cd935b03c0d2a2072369a6b463146432005d6345199b88054b2787a71" [[metadata.targets]] -requires_python = ">=3.8" +requires_python = ">=3.9" [[package]] name = "black" -version = "24.8.0" -requires_python = ">=3.8" +version = "24.10.0" +requires_python = ">=3.9" summary = "The uncompromising code formatter." groups = ["dev"] dependencies = [ @@ -26,28 +26,28 @@ dependencies = [ "typing-extensions>=4.0.1; python_version < \"3.11\"", ] files = [ - {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, - {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, - {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, - {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, - {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, - {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, - {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, - {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, - {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, - {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, - {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, - {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, - {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, - {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, - {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, - {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, - {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, - {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, - {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, - {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, - {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, - {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, ] [[package]] @@ -79,83 +79,73 @@ files = [ [[package]] name = "coverage" -version = "7.6.1" -requires_python = ">=3.8" +version = "7.6.10" +requires_python = ">=3.9" summary = "Code coverage measurement for Python" groups = ["dev"] files = [ - {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, - {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, - {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, - {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, - {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, - {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, - {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, - {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, - {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, - {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, - {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, - {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, - {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, - {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, - {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, - {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, - {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, - {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, + {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, + {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, + {file = "coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a"}, + {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165"}, + {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988"}, + {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5"}, + {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3"}, + {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5"}, + {file = "coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244"}, + {file = "coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e"}, + {file = "coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3"}, + {file = "coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43"}, + {file = "coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132"}, + {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f"}, + {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994"}, + {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99"}, + {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd"}, + {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377"}, + {file = "coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8"}, + {file = "coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609"}, + {file = "coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853"}, + {file = "coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078"}, + {file = "coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0"}, + {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50"}, + {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022"}, + {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b"}, + {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0"}, + {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852"}, + {file = "coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359"}, + {file = "coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247"}, + {file = "coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9"}, + {file = "coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b"}, + {file = "coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690"}, + {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18"}, + {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c"}, + {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd"}, + {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e"}, + {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694"}, + {file = "coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6"}, + {file = "coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e"}, + {file = "coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe"}, + {file = "coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273"}, + {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8"}, + {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098"}, + {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb"}, + {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0"}, + {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf"}, + {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2"}, + {file = "coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312"}, + {file = "coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d"}, + {file = "coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a"}, + {file = "coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27"}, + {file = "coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4"}, + {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f"}, + {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25"}, + {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315"}, + {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90"}, + {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d"}, + {file = "coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18"}, + {file = "coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59"}, + {file = "coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f"}, + {file = "coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index a0b9e44..af1a92f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "minicfg" -version = "2.0.1" -description = "Lightweight and opinionated config library for your Python services." +version = "3.0.0" +description = "minicfg is a lightweight, minimalistic and easy-to-use configuration package for your Python services." authors = [ {name = "jieggii", email = "jieggii@protonmail.com"}, ] dependencies = [] -requires-python = ">=3.8" +requires-python = ">=3.9" readme = "README.md" license = {text = "MIT"} diff --git a/tests/_mock_provider.py b/tests/_mock_provider.py new file mode 100644 index 0000000..382f829 --- /dev/null +++ b/tests/_mock_provider.py @@ -0,0 +1,13 @@ +from minicfg.provider import AbstractProvider + + +class MockProvider(AbstractProvider): + """ + A mock provider used for testing purposes. + """ + + def __init__(self, data: dict[str, str]): + self._data = data + + def get(self, key: str) -> str | None: + return self._data.get(key) diff --git a/tests/test_caster.py b/tests/test_caster.py index 813e949..65a639c 100644 --- a/tests/test_caster.py +++ b/tests/test_caster.py @@ -1,12 +1,15 @@ import unittest -from minicfg.caster import BoolCaster, FloatCaster, IntCaster, ListCaster +from minicfg.caster import AbstractCaster, BoolCaster, FloatCaster, IntCaster, JSONCaster, ListCaster class TestIntCaster(unittest.TestCase): def setUp(self): self.caster = IntCaster() + def test_typename(self): + self.assertEqual("int", self.caster.typename) + def test_positive(self): result = self.caster.cast("123123") self.assertEqual(result, 123123) @@ -28,6 +31,9 @@ class TestFloatCaster(unittest.TestCase): def setUp(self): self.caster = FloatCaster() + def test_type_name(self): + self.assertEqual("float", self.caster.typename) + def test_positive(self): result = self.caster.cast("123.456") self.assertEqual(result, 123.456) @@ -49,13 +55,16 @@ class TestBoolCaster(unittest.TestCase): def setUp(self): self.caster = BoolCaster() + def test_typename(self): + self.assertEqual("bool", self.caster.typename) + def test_true_values(self): - true_values = ["true", "yes", "on", "enable", "1"] + true_values = ["true", "yes", "on", "enable", "enabled", "1"] for value in true_values: self.assertTrue(self.caster.cast(value)) def test_false_values(self): - false_values = ["false", "no", "off", "disable", "0"] + false_values = ["false", "no", "off", "disable", "disabled", "0"] for value in false_values: self.assertFalse(self.caster.cast(value)) @@ -69,6 +78,33 @@ def setUp(self): self.separator = "," self.caster = ListCaster(sep=self.separator) + def test_typename_str(self): + self.assertEqual("list[str]", self.caster.typename) + + def test_typename_with_item_caster_with_typename(self): + class MockCaster(AbstractCaster): + @property + def typename(self) -> str: + return "item-caster-typename" + + def cast(self, value: str) -> str: + raise NotImplementedError() + + list_caster = ListCaster(sep=self.separator, item_caster=MockCaster()) + self.assertEqual("list[item-caster-typename]", list_caster.typename) + + def test_typename_with_item_caster_without_typename(self): + class MockCaster(AbstractCaster): + @property + def typename(self) -> str | None: + return None + + def cast(self, value: str) -> str: + raise NotImplementedError() + + list_caster = ListCaster(sep=self.separator, item_caster=MockCaster()) + self.assertEqual("list", list_caster.typename) + def test_list_of_strings(self): result = self.caster.cast("a,b,c") self.assertEqual(result, ["a", "b", "c"]) @@ -79,6 +115,12 @@ def test_list_with_int_caster(self): result = list_caster.cast("1,2,3") self.assertEqual(result, [1, 2, 3]) + def test_list_with_json_caster(self): + json_caster = JSONCaster() + list_caster = ListCaster(sep=";", item_caster=json_caster) + result = list_caster.cast('{"hello": "world", "foo": "bar"};{"hi": "friend"}') + self.assertEqual(result, [{"hello": "world", "foo": "bar"}, {"hi": "friend"}]) + def test_list_with_invalid_item(self): int_caster = IntCaster() list_caster = ListCaster(sep=self.separator, item_caster=int_caster) @@ -86,5 +128,38 @@ def test_list_with_invalid_item(self): list_caster.cast("1,invalid,3") +class TestJSONCaster(unittest.TestCase): + def setUp(self): + self.caster = JSONCaster() + + def test_typename(self): + self.assertEqual("json", self.caster.typename) + + def test_json_object(self): + py_dict = {"str": "hello!", "int": 1, "flag": True, "float": 1.1} + json = '{"str": "hello!", "int": 1, "flag": true, "float": 1.1}' + + self.assertEqual(py_dict, self.caster.cast(json)) + + def test_json_array(self): + py_list = ["hello!", 1, True, 1.1] + json = '["hello!", 1, true, 1.1]' + + self.assertEqual(py_list, self.caster.cast(json)) + + def test_custom_load(self): + def custom_load(value: str) -> dict: + return {"custom": value} + + caster = JSONCaster(load=custom_load) + self.assertEqual({"custom": "test"}, caster.cast("test")) + + def test_invalid_json(self): + json = "bla bla bla" + + with self.assertRaises(ValueError): + self.caster.cast(json) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_field.py b/tests/test_field.py index 7994510..54ec60f 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -1,89 +1,103 @@ import unittest -from unittest.mock import Mock -from minicfg.field import Field, _NOT_SET -from minicfg.exceptions import CastingError, FieldConflictError, FieldValueNotProvidedError -from minicfg.provider import AbstractProvider +import unittest.mock + from minicfg.caster import AbstractCaster +from minicfg.field import NO_DEFAULT_VALUE, CastingError, Field, FieldValueNotProvidedError + +from ._mock_provider import MockProvider class TestField(unittest.TestCase): def test_field_initialization_with_default_values(self): field = Field(name="test_field") self.assertEqual(field.name, "test_field") - self.assertEqual(field._default, _NOT_SET) + self.assertEqual(field._default, NO_DEFAULT_VALUE) self.assertIsNone(field._caster) - self.assertFalse(field._attach_file_field) - self.assertEqual(field._populated_value, _NOT_SET) + self.assertIsNone(field._description) + self.assertFalse(field._file_field) + self.assertEqual(field._value, None) - def test_field_populate_with_provider_value(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = "123" - caster = Mock(spec=AbstractCaster) - caster.cast.return_value = 123 - - field = Field(name="test_field", caster=caster) - field.populate(provider) + def test_description(self): + field = Field(name="test_field", description="description") + self.assertEqual(field.description, "description") - self.assertEqual(field.populated_value, 123) - provider.get.assert_called_with("test_field") - caster.cast.assert_called_with("123") + def test_default(self): + field = Field(name="test_field", default="default value") + self.assertEqual(field.default, "default value") - def test_field_populate_with_default_value(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = None + def test_populate_using_provided_value(self): + provider = MockProvider({"test_field": "hello world"}) - field = Field(name="test_field", default=456) + field = Field("test_field") field.populate(provider) - self.assertEqual(field.populated_value, 456) + self.assertEqual(field.value, "hello world") - def test_field_populate_with_file_value(self): - provider = Mock(spec=AbstractProvider) - provider.get.side_effect = lambda x: "file_path" if x == "test_field_FILE" else None + def test_populate_using_default(self): + provider = MockProvider({}) + field = Field(name="test_field", default="hello") + field.populate(provider) + self.assertEqual(field.value, "hello") - field = Field(name="test_field", attach_file_field=True) - with unittest.mock.patch("minicfg.field._read_raw_value_from_file", return_value="789"): + def test_populate_no_value_and_no_default(self): + provider = MockProvider({}) + field = Field(name="test_field") + with self.assertRaises(FieldValueNotProvidedError): field.populate(provider) - self.assertEqual(field.populated_value, "789") + def test_populate_casting_error(self): + provider = MockProvider({"test_field": "hello"}) + + class MockCaster(AbstractCaster): + def typename(self) -> str | None: + return None - def test_field_populate_with_file_value_raises_casting_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.side_effect = lambda x: "file_path" if x == "test_field_FILE" else None - caster = Mock(spec=AbstractCaster) - caster.cast.side_effect = ValueError("Invalid cast") + def cast(self, value: str) -> int: + raise ValueError("casting error") - field = Field(name="test_field", attach_file_field=True, caster=caster) - with unittest.mock.patch("minicfg.field._read_raw_value_from_file", return_value="invalid_value"): - with self.assertRaises(CastingError): - field.populate(provider) + field = Field(name="test_field", caster=MockCaster()) + with self.assertRaises(CastingError): + field.populate(provider) - def test_field_populate_raises_field_conflict_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.side_effect = lambda x: "value" if x == "test_field" else "file_path" + def test_populate_with_file_field_only_file_field(self): + provider = MockProvider({"test_field_FILE": "file_path"}) field = Field(name="test_field", attach_file_field=True) - with self.assertRaises(FieldConflictError): + with unittest.mock.patch("minicfg.field._read_raw_value_from_file", return_value="test value"): field.populate(provider) - def test_field_populate_raises_casting_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = "invalid_value" - caster = Mock(spec=AbstractCaster) - caster.cast.side_effect = ValueError("Invalid cast") + self.assertEqual(field.value, "test value") - field = Field(name="test_field", caster=caster) - with self.assertRaises(CastingError): - field.populate(provider) + def test_populate_with_file_field_only_field(self): + provider = MockProvider({"test_field": "value"}) - def test_field_populate_raises_field_value_not_provided_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = None + field = Field(name="test_field", attach_file_field=True) + field.populate(provider) - field = Field(name="test_field") + self.assertEqual(field.value, "value") + + def test_populate_with_file_field_both_provided(self): + provider = MockProvider({"test_field": "value", "test_field_FILE": "file_path"}) + + field = Field(name="test_field", attach_file_field=True) + field.populate(provider) + + self.assertEqual(field.value, "value") + + def test_populate_with_file_field_no_value(self): + provider = MockProvider({}) + + field = Field(name="test_field", attach_file_field=True) with self.assertRaises(FieldValueNotProvidedError): field.populate(provider) + def test_populate_with_file_field_no_value_default(self): + provider = MockProvider({}) + + field = Field(name="test_field", attach_file_field=True, default="default value") + field.populate(provider) + self.assertEqual(field.value, "default value") + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_minicfg.py b/tests/test_minicfg.py index fdcf4c6..d91eafd 100644 --- a/tests/test_minicfg.py +++ b/tests/test_minicfg.py @@ -1,77 +1,150 @@ import unittest -from unittest.mock import Mock, patch +import unittest.mock -from minicfg.field import Field, FieldConflictError, CastingError, FieldValueNotProvidedError, _NOT_SET +from minicfg import Field, Minicfg, minicfg_name +from minicfg.minicfg import _DEFAULT_NAME_SEP, minicfg_name_sep from minicfg.provider import AbstractProvider -from minicfg.caster import AbstractCaster - - -class TestField(unittest.TestCase): - - def test_field_initialization(self): - field = Field(name="test_field", default="default_value", attach_file_field=True) - self.assertEqual(field.name, "test_field") - self.assertEqual(field._default, "default_value") - self.assertTrue(field._attach_file_field) - self.assertEqual(field._populated_value, _NOT_SET) - - def test_field_name_property(self): - field = Field(name="test_field") - self.assertEqual(field.name, "test_field") - field.name = "new_name" - self.assertEqual(field.name, "new_name") - - def test_field_populated_value_property(self): - field = Field() - self.assertEqual(field.populated_value, _NOT_SET) - field._populated_value = "populated_value" - self.assertEqual(field.populated_value, "populated_value") - - def test_populate_with_raw_value(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = "raw_value" - field = Field(name="test_field") - field.populate(provider) - self.assertEqual(field.populated_value, "raw_value") - - def test_populate_with_file_field(self): - provider = Mock(spec=AbstractProvider) - provider.get.side_effect = lambda key: "file_path" if key == "test_field_FILE" else None - field = Field(name="test_field", attach_file_field=True) - with patch("minicfg.field._read_raw_value_from_file", return_value="file_value"): - field.populate(provider) - self.assertEqual(field.populated_value, "file_value") - - def test_populate_with_default_value(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = None - field = Field(name="test_field", default="default_value") - field.populate(provider) - self.assertEqual(field.populated_value, "default_value") - - def test_populate_raises_field_conflict_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.side_effect = lambda key: "raw_value" if key == "test_field" else "file_value" - field = Field(name="test_field", attach_file_field=True) - with self.assertRaises(FieldConflictError): - field.populate(provider) - - def test_populate_raises_casting_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = "raw_value" - caster = Mock(spec=AbstractCaster) - caster.cast.side_effect = Exception("casting error") - field = Field(name="test_field", caster=caster) - with self.assertRaises(CastingError): - field.populate(provider) - - def test_populate_raises_field_value_not_provided_error(self): - provider = Mock(spec=AbstractProvider) - provider.get.return_value = None - field = Field(name="test_field") - with self.assertRaises(FieldValueNotProvidedError): - field.populate(provider) + +from ._mock_provider import MockProvider + + +class TestMinicfg(unittest.TestCase): + def test_init_defaults(self): + class Config(Minicfg): + pass + + config = Config() + self.assertEqual(config._name, None) + self.assertEqual(config._name_sep, _DEFAULT_NAME_SEP) + + def test_init(self): + class Config(Minicfg): + field_name = Field() + + config = Config() + self.assertEqual(config.field_name.name, "field_name") + + def test_init_nested(self): + @minicfg_name("config") + class Config(Minicfg): + @minicfg_name("nested") + class Nested(Minicfg): + field_name = Field() + + config = Config() + self.assertEqual(config.Nested.field_name.name, "config_nested_field_name") + + def test_init_nested_inherit_name(self): + @minicfg_name("config") + class Config(Minicfg): + class Nested(Minicfg): + field_name = Field() + + config = Config() + self.assertEqual("config_field_name", config.Nested.field_name.name) + + def test_init_update_file_field_name(self): + @minicfg_name("config") + class Config(Minicfg): + field_name = Field(attach_file_field=True) + + config = Config() + self.assertEqual(f"config_field_name_FILE", config.field_name.file_field.name) + + def test_init_nested_sep(self): + sep1 = "-" + sep2 = "_" + + @minicfg_name("config") + @minicfg_name_sep(sep1) + class Config(Minicfg): + @minicfg_name("nested") + @minicfg_name_sep(sep2) + class Nested(Minicfg): + field_name = Field() + + config = Config() + self.assertEqual(f"config{sep1}nested{sep2}field_name", config.Nested.field_name.name) + + def test_new_populated(self): + class Config(Minicfg): + field_name = Field() + + provider = MockProvider({"field_name": "hello"}) + config = Config.new_populated(provider) + self.assertEqual("hello", config.field_name) + + def test_populate(self): + class Config(Minicfg): + field_name = Field() + + provider = MockProvider({"field_name": "hello"}) + config = Config() + config.populate(provider) + self.assertEqual("hello", config.field_name) + + def test_populate_nested(self): + @minicfg_name("config") + class Config(Minicfg): + field_name = Field() + + @minicfg_name("nested1") + class Nested1(Minicfg): + field_name = Field() + + @minicfg_name("nested2") + class Nested2(Minicfg): + field_name = Field() + + provider = MockProvider( + { + "config_field_name": "1", + "config_nested1_field_name": "2", + "config_nested1_nested2_field_name": "3", + } + ) + + config = Config() + config.populate(provider) + + self.assertEqual("1", config.field_name) + self.assertEqual("2", config.Nested1.field_name) + self.assertEqual("3", config.Nested1.Nested2.field_name) + + def test_iter(self): + class Config(Minicfg): + field_name = Field() + + class Child(Minicfg): + field_name = Field() + + config = Config() + + for child in config: + self.assertTrue(isinstance(child, Field) or isinstance(child, Minicfg)) + + +class TestDecorators(unittest.TestCase): + def test_minicfg_name(self): + name = "TEST_NAME" + + @minicfg_name(name) + class Config(Minicfg): + pass + + config = Config() + self.assertEqual(config._name, name) + + def test_minicfg_name_sep(self): + sep = "test_sep" + + @minicfg_name_sep(sep) + class Config(Minicfg): + pass + + config = Config() + self.assertEqual(config._name_sep, sep) if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()