diff --git a/,pyup.yml b/,pyup.yml index 5ae9213..0002c11 100644 --- a/,pyup.yml +++ b/,pyup.yml @@ -1,6 +1,14 @@ # autogenerated pyup.io config file # see https://pyup.io/docs/configuration/ for all available options -schedule: 'every month' -update: false -pin: False +# set the default branch +# default: empty, the default branch on GitHub +branch: Develop + +requirements: + - docs/requirements.txt: + update: False + - requirements.txt: + update: False + - requirements_setup.txt: + update: False diff --git a/.github/workflows/run_tox.yml b/.github/workflows/run_tox.yml index 3bde7a9..26f1629 100644 --- a/.github/workflows/run_tox.yml +++ b/.github/workflows/run_tox.yml @@ -4,7 +4,7 @@ on: [push, pull_request] jobs: pre-commit: - name: + name: pre-commit runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 29b5c6a..270c5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ .mypy_cache __pycache__ + +make.bat diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa0bef0..c33dc5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,3 +31,9 @@ repos: hooks: - id: check-hooks-apply - id: check-useless-excludes + + - repo: https://github.com/asottile/pyupgrade + rev: v3.9.0 + hooks: + - id: pyupgrade + args: ["--py38-plus"] diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..a83e388 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,30 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Build documentation with MkDocs +#mkdocs: +# configuration: mkdocs.yml + +# Optionally build your docs in additional formats such as PDF and ePub +formats: all + +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +# Optionally set the version of Python and requirements required to build your docs +python: + install: + - requirements: requirements_setup.txt + - requirements: docs/requirements.txt + - method: setuptools + path: . diff --git a/docs/conf.py b/docs/conf.py index d26c021..0efd5b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx_autodoc_typehints', + 'sphinx_exec_code' ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/index.rst b/docs/index.rst index e960d1c..c1b2a57 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Welcome to the easyconfig documentation! :maxdepth: 2 :caption: Contents: + usage class_reference diff --git a/docs/requirements.txt b/docs/requirements.txt index 3a9e8c3..f43100b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ # Packages required to build the documentation -sphinx >= 5.3, < 6 -sphinx-autodoc-typehints >= 1.20.1, < 2 -sphinx_rtd_theme >= 1.1.1, < 2 +sphinx == 6.2.1 +sphinx-autodoc-typehints == 1.23.0 +sphinx_rtd_theme == 1.2.2 +sphinx-exec-code == 0.10 diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..0116069 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,224 @@ +************************************** +Usage +************************************** + +Create your models as you did before. Then pass an instance of the model to the easyconfig function. +It will create a mutable object from the model that holds the same values. + +Easyconfig also provides some mixin classes, so you can have type hints for the file load functions. +These mixins are not required, they are just there to provide type hints in the IDE. + +For convenience reasons you can also import ``AppBaseModel`` and ``BaseModel`` from ``easyconfig`` so you don't have to +inherit from the mixins yourself. + + +Simple example +-------------------------------------- +.. exec_code:: + :language_output: yaml + :caption_output: Generated yaml file + + from pydantic import BaseModel + from easyconfig import AppConfigMixin, create_app_config + + + class MySimpleAppConfig(BaseModel, AppConfigMixin): + retries: int = 5 + url: str = 'localhost' + port: int = 443 + + + # Create a global variable which then can be used throughout your code + CONFIG = create_app_config(MySimpleAppConfig()) + + # Use with type hints and auto complete + CONFIG.port + + # Load configuration file from disk. + # If the file does not exist it will be created + # Loading will also change all values of CONFIG accordingly + # ------------ skip: start ------------ + CONFIG.load_config_file('/my/configuration/file.yml') + # ------------ skip: stop ------------- + # ------------ hide: start ------------- + print(CONFIG.generate_default_yaml()) + # ------------ hide: stop ------------- + + +Nested example +-------------------------------------- +Nested example with the convenience base classes from easyconfig. + +.. exec_code:: + :language_output: yaml + :caption_output: Generated yaml file + + from pydantic import Field + from easyconfig import AppBaseModel, BaseModel, create_app_config + + + class HttpConfig(BaseModel): + retries: int = 5 + url: str = 'localhost' + port: int = 443 + + + class MySimpleAppConfig(AppBaseModel): + run_at: int = Field(12, alias='run at') # use alias to load from/create a different key + http: HttpConfig = HttpConfig() + + + CONFIG = create_app_config(MySimpleAppConfig()) + # ------------ skip: start ------------ + CONFIG.load_config_file('/my/configuration/file.yml') + # ------------ skip: stop ------------- + # ------------ hide: start ------------- + print(CONFIG.generate_default_yaml()) + # ------------ hide: stop ------------- + + +Description and comments +-------------------------------------- +It's possible to specify a description through the pydantic ``Field``. +The description will be created as a comment in the .yml file. +Note that the comments will be aligned properly + +.. exec_code:: + :language_output: yaml + :caption_output: Generated yaml file + + from pydantic import Field + from easyconfig import AppBaseModel, create_app_config + + + class MySimpleAppConfig(AppBaseModel): + retries: int = Field(5, description='Amount of retries on error') + url: str = Field('localhost', description='Url used for connection') + port: int = 443 + + + CONFIG = create_app_config(MySimpleAppConfig()) + # ------------ skip: start ------------ + CONFIG.load_config_file('/my/configuration/file.yml') + # ------------ skip: stop ------------- + # ------------ hide: start ------------- + print(CONFIG.generate_default_yaml()) + # ------------ hide: stop ------------- + + +Expansion and docker secrets +-------------------------------------- +It's possible to use environment variable or files for expansion. +To expand an environment variable or file use ``${NAME}`` or ``${NAME:DEFAULT}`` to specify an additional default if the +value under ``NAME`` is not set. +To load the content from a file, e.g. a docker secret specify an absolute file name. + +Environment variables:: + + MY_USER =USER_NAME + MY_GROUP=USER: ${MY_USER}, GROUP: GROUP_NAME + ENV_{_SIGN = CURLY_OPEN_WORKS + ENV_}_SIGN = CURLY_CLOSE_WORKS + + +yaml file + +.. exec_code:: + :language_output: yaml + :hide_code: + + a = """ + env_var: "${MY_USER}" + env_var_recursive: "${MY_GROUP}" + env_var_not_found: Does not exist -> "${INVALID_NAME}" + env_var_default: Does not exist -> "${INVALID_NAME:DEFAULT_VALUE}" + file: "${/my_file/path.txt}" + escaped: | + Brackets {} or $ signs can be used as expected. + Use $${BLA} to escape the whole expansion. + Use $} to escape the closing bracket, e.g. use "${ENV_$}_SIGN}" for "ENV_}_SIGN" + The { does not need to be escaped, e.g. use "${ENV_{_SIGN}" for "ENV_{_SIGN" + """ + + print(a) + + +.. exec_code:: + :language_output: yaml + :hide_code: + :caption_output: After expansion + + + from io import StringIO + from easyconfig.yaml import cmap_from_model, write_aligned_yaml, yaml_rt + from easyconfig.expansion import expand_obj + from easyconfig.expansion import load_file as load_file_module + from os import environ + + + a = """ + env_var: "${MY_USER}" + env_var_recursive: "${MY_GROUP}" + env_var_not_found: Does not exist -> "${INVALID_NAME}" + env_var_default: Does not exist -> "${INVALID_NAME:DEFAULT_VALUE}" + file: "${/my_file/path.txt}" + escaped: | + Brackets {} or $ signs can be used as expected. + Use $${BLA} to escape the whole expansion. + Use $} to escape the closing bracket, e.g. use "${ENV_$}_SIGN}" for "ENV_}_SIGN" + The { does not need to be escaped, e.g. use "${ENV_{_SIGN}" for "ENV_{_SIGN" + """ + + load_file_module.read_file = lambda x: "" + environ['MY_USER'] = 'USER_NAME' + environ['MY_GROUP'] = 'USER: ${MY_USER}, GROUP: GROUP_NAME' + environ['ENV_{_SIGN'] = 'CURLY_OPEN_WORKS' + environ['ENV_}_SIGN'] = 'CURLY_CLOSE_WORKS' + + file = StringIO(a) + cfg = yaml_rt.load(file) + expand_obj(cfg) + + out = StringIO() + yaml_rt.dump(cfg, out) + print(out.getvalue()) + + +Callbacks +-------------------------------------- + +It's possible to register callbacks that will get executed when a value changes or +when the configuration gets loaded for the first time. +This is especially useful feature if the application allows dynamic reloading of the configuration file +(e.g. through a file watcher). + +.. exec_code:: + :language_output: yaml + :caption_output: Generated yaml file + + from easyconfig import AppBaseModel, create_app_config + + class MySimpleAppConfig(AppBaseModel): + retries: int = 5 + url: str = 'localhost' + port: int = 443 + + # A function that does the setup + def setup_http(): + # some internal function + create_my_http_client(CONFIG.url, CONFIG.port) + + CONFIG = create_app_config(MySimpleAppConfig()) + + # setup_http will be automatically called if a value changes in the MyAppSimpleConfig + # during a subsequent call to CONFIG.load_file() or + # when the config gets loaded for the first time + sub = CONFIG.subscribe_for_changes(setup_http) + + # It's possible to cancel the subscription again + sub.cancel() + + # ------------ skip: start ------------ + # This will trigger the callback + CONFIG.load_config_file('/my/configuration/file.yml') + # ------------ skip: stop ------------- diff --git a/readme.md b/readme.md index f03ec3e..5c49f2f 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,16 @@ and easyconfig builds on that. It's possible to use all pydantic features and model features so every exotic use case should be covered. If you have previously worked with pydantic you should feel right at home +## Documentation +[The documentation can be found at here](https://easyconfig.readthedocs.io) + +## Features + +- Default `.yml` file generation +- Environment variable expansion +- Support for docker secrets +- Callbacks when values of the configuration change + ## Why not pydantic settings A pydantic settings object is a non-mutable object. With easyconfig you can create a global configuration and just import it into your modules. @@ -25,138 +35,15 @@ Additionally, easyconfig can create a default configuration file with the specif values and comments of the pydantic models. That way the users can have some guidance how to change the program behaviour. -## Usage -Create your models as you did before. Then pass an instance of the model to the easyconfig function. -It will create a mutable object from the model that holds the same values. - -Easyconfig also provides some mixin classes, so you can have type hints for the file load functions. -These mixins are not required, they are just there to provide type hints in the IDE. - -For convenience reasons you can also import ``AppBaseModel`` and ``BaseModel`` from ``easyconfig`` so you don't have to -inherit from the mixins yourself. - -### Simple example - -```python -from pydantic import BaseModel -from easyconfig import AppConfigMixin, create_app_config - - -class MySimpleAppConfig(BaseModel, AppConfigMixin): - retries: int = 5 - url: str = 'localhost' - port: int = 443 - - -# Create a global variable which then can be used throughout your code -CONFIG = create_app_config(MySimpleAppConfig()) - -# Use with type hints and auto complete -print(CONFIG.port) - -# Load configuration file from disk. -# If the file does not exist it will be created -# Loading will also change all values of CONFIG accordingly -CONFIG.load_config_file('/my/configuration/file.yml') -``` -Created configuration file: -```yaml -retries: 5 -url: localhost -port: 443 -``` - -### Nested example - -Nested example with the convenience base classes from easyconfig. - -```python -from pydantic import Field -from easyconfig import AppBaseModel, BaseModel, create_app_config - - -class HttpConfig(BaseModel): - retries: int = 5 - url: str = 'localhost' - port: int = 443 - - -class MyAppSimpleConfig(AppBaseModel): - run_at: int = Field(12, alias='run at') # use alias to load from/create a different key - http: HttpConfig = HttpConfig() - -CONFIG = create_app_config(MyAppSimpleConfig()) -CONFIG.load_config_file('/my/configuration/file.yml') - -``` -Created configuration file: -```yaml -run at: 12 -http: - retries: 5 - url: localhost - port: 443 -``` - - -### Comments -It's possible to specify a description through the pydantic ``Field``. -The description will be created as a comment in the .yml file - -```python -from pydantic import Field -from easyconfig import AppBaseModel, create_app_config - - -class MySimpleAppConfig(AppBaseModel): - retries: int = Field(5, description='Amount of retries on error') - url: str = 'localhost' - port: int = 443 - - -CONFIG = create_app_config(MySimpleAppConfig()) -CONFIG.load_config_file('/my/configuration/file.yml') -``` -Created configuration file: -```yaml -retries: 5 # Amount of retries on error -url: localhost -port: 443 -``` - -### Callbacks -It's possible to register callbacks that will get executed when a value changes or -when the configuration gets loaded for the first time. A useful feature if the application allows dynamic reloading -of the configuration file (e.g. through a file watcher). - -```python -from easyconfig import AppBaseModel, create_app_config - - -class MySimpleAppConfig(AppBaseModel): - retries: int = 5 - url: str = 'localhost' - port: int = 443 - - -def setup_http(): - # some internal function - create_my_http_client(CONFIG.url, CONFIG.port) - - -CONFIG = create_app_config(MySimpleAppConfig()) -CONFIG.load_config_file('/my/configuration/file.yml') - -# setup_http will be automatically called if a value changes in the MyAppSimpleConfig -# during a subsequent call to CONFIG.load_file() or when the config gets loaded for the first time -sub = CONFIG.subscribe_for_changes(setup_http) - -# It's possible to cancel the subscription again -sub.cancel() -``` +### Expansion +It's possible to use environment variable or files for expansion. Easyconfig will load all values # Changelog +#### 0.3.0 (2023-03-17) +- Breaking: requires pydantic 2.0 +- Added support for variable expansion through environment variables and docker secrets + #### 0.2.8 (2023-02-08) - Fix for StrictBool diff --git a/requirements.txt b/requirements.txt index 215032b..7e15bae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,5 @@ -r requirements_setup.txt # testing dependencies -pytest >= 7.2, < 8 -pre-commit >= 3.0, < 3.1 +pytest >= 7.4, < 8 +pre-commit >= 3.3, < 4 diff --git a/requirements_setup.txt b/requirements_setup.txt index bf0db2f..39453de 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,3 +1,4 @@ -pydantic >= 1.9.0, < 2.0 +pydantic >= 2.0, < 3.0 +pydantic-settings >= 2.0, < 3.0 ruamel.yaml >= 0.17, < 0.18 typing-extensions >= 4.4, < 5 diff --git a/src/easyconfig/__version__.py b/src/easyconfig/__version__.py index 14e974f..0404d81 100644 --- a/src/easyconfig/__version__.py +++ b/src/easyconfig/__version__.py @@ -1 +1 @@ -__version__ = '0.2.8' +__version__ = '0.3.0' diff --git a/src/easyconfig/config_objs/app_config.py b/src/easyconfig/config_objs/app_config.py index 2581d99..1e05bfa 100644 --- a/src/easyconfig/config_objs/app_config.py +++ b/src/easyconfig/config_objs/app_config.py @@ -6,6 +6,7 @@ from typing_extensions import Self from easyconfig.__const__ import MISSING, MISSING_TYPE +from easyconfig.expansion import expand_obj from easyconfig.yaml import cmap_from_model, CommentedMap, write_aligned_yaml, yaml_rt from ..errors import FileDefaultsNotSetError @@ -21,23 +22,42 @@ def __init__(self, model: BaseModel, path: Tuple[str, ...] = ('__root__',), self._file_path: Optional[Path] = None def set_file_path(self, path: Union[Path, str]): + """Set the path to the configuration file. + If no file extension is specified ``.yml`` will be automatically appended. + + :param path: Path obj or str + """ if isinstance(path, str): path = Path(path) if not isinstance(path, Path): - raise RuntimeError(f'Path to configuration file not specified: {path}') + raise ValueError(f'Path to configuration file not of type Path: {path} ({type(path)})') self._file_path = path.resolve() if not self._file_path.suffix: self._file_path = self._file_path.with_suffix('.yml') - def load_config_dict(self, cfg: dict): + def load_config_dict(self, cfg: dict, /, expansion: bool = True): + """Load the configuration from a dictionary + + :param cfg: config dict which will be loaded + :param expansion: Expand ${...} in strings + """ + if expansion: + expand_obj(cfg) + # validate data model_obj = self._obj_model_class(**cfg) # update mutable objects self._set_values(model_obj) + return self + + def load_config_file(self, path: Union[Path, str] = None, expansion: bool = True): + """Load configuration from a yaml file. If the file does not exist a default file will be created - def load_config_file(self, path: Union[Path, str] = None): + :param path: Path to file + :param expansion: Expand ${...} in strings + """ if path is not None: self.set_file_path(path) assert isinstance(self._file_path, Path) @@ -55,10 +75,14 @@ def load_config_file(self, path: Union[Path, str] = None): cfg = CommentedMap() # load c_map data (which is a dict) - self.load_config_dict(cfg) + self.load_config_dict(cfg, expansion=expansion) + return self def generate_default_yaml(self) -> str: + """Generate the default YAML structure + :returns: YAML structure as a string + """ if self._file_defaults is None: raise FileDefaultsNotSetError() diff --git a/src/easyconfig/config_objs/object_config.py b/src/easyconfig/config_objs/object_config.py index 9422f0f..1f7af49 100644 --- a/src/easyconfig/config_objs/object_config.py +++ b/src/easyconfig/config_objs/object_config.py @@ -1,9 +1,8 @@ from inspect import getmembers, isfunction -from typing import Any, Callable, Dict, List, Tuple, Type, TYPE_CHECKING, TypeVar, Union +from typing import Any, Callable, Dict, Final, List, Tuple, Type, TYPE_CHECKING, TypeVar, Union from pydantic import BaseModel -from pydantic.fields import ModelField -from typing_extensions import Final +from pydantic.fields import FieldInfo from easyconfig import AppConfigMixin from easyconfig.__const__ import MISSING, MISSING_TYPE @@ -30,7 +29,7 @@ def __init__(self, model: BaseModel, self._obj_path: Final = path self._obj_model_class: Final = model.__class__ - self._obj_model_fields: Dict[str, ModelField] = model.__fields__ + self._obj_model_fields: Dict[str, FieldInfo] = model.model_fields self._obj_model_private_attrs: List[str] = list(model.__private_attributes__.keys()) self._obj_keys: Tuple[str, ...] = () @@ -153,6 +152,13 @@ def __repr__(self): # ------------------------------------------------------------------------------------------------------------------ def subscribe_for_changes(self, func: Callable[[], Any], propagate: bool = False, on_next_load: bool = True) \ -> 'easyconfig.config_objs.ConfigObjSubscription': + """When a value in this container changes the passed function will be called. + + :param func: function which will be called + :param propagate: Propagate the change event to the parent object + :param on_next_load: Call the function the next time when values get loaded even if there is no value change + :return: object which can be used to cancel the subscription + """ target = f'{func.__name__} @ {self._full_obj_path}' for sub in self._obj_subscriptions: @@ -163,6 +169,8 @@ def subscribe_for_changes(self, func: Callable[[], Any], propagate: bool = False self._obj_subscriptions.append(sub) return ConfigObjSubscription(sub, target) + + @classmethod def parse_obj(cls, *args, **kwargs): raise FunctionCallNotAllowedError() diff --git a/src/easyconfig/create_app_config.py b/src/easyconfig/create_app_config.py index bb06e0b..842d926 100644 --- a/src/easyconfig/create_app_config.py +++ b/src/easyconfig/create_app_config.py @@ -11,13 +11,16 @@ TYPE_DEFAULTS = Union[BaseModel, Dict[str, Any]] +# noinspection PyProtectedMember def check_field_args(model: AppConfig, allowed: FrozenSet[str]): """Check extra args of pydantic fields""" # Model fields for name, field in model._obj_model_fields.items(): - if not set(field.field_info.extra).issubset(allowed): - forbidden = sorted(set(field.field_info.extra) - allowed) + if (extras := field.json_schema_extra) is None: + continue + if not set(extras).issubset(allowed): + forbidden = sorted(set(extras) - allowed) raise ExtraKwArgsNotAllowedError( f'Extra kwargs for field "{name}" of {model._last_model.__class__.__name__} are not allowed: ' f'{", ".join(forbidden)}' @@ -71,6 +74,6 @@ def create_app_config( if file_values is not None and validate_file_values: _yaml = app_cfg.generate_default_yaml() _dict = yaml_rt.load(_yaml) - model.__class__.parse_obj(_dict) + model.__class__.model_validate(_dict) return app_cfg diff --git a/src/easyconfig/errors/errors.py b/src/easyconfig/errors/errors.py index f094e17..6ec3a22 100644 --- a/src/easyconfig/errors/errors.py +++ b/src/easyconfig/errors/errors.py @@ -21,3 +21,14 @@ class FileDefaultsNotSetError(EasyConfigError): class FunctionCallNotAllowedError(EasyConfigError): def __init__(self): super().__init__('Call "load_config_dict" or "load_config_file" on the app config instance!') + + +# ----------------------------------------------------------------------------- +# Errors related to environment variable replacement +# ----------------------------------------------------------------------------- +class EasyConfigEnvVarError(EasyConfigError): + pass + + +class CyclicEnvironmentVariableReferenceError(EasyConfigEnvVarError): + pass diff --git a/src/easyconfig/expansion/__init__.py b/src/easyconfig/expansion/__init__.py new file mode 100644 index 0000000..fa97464 --- /dev/null +++ b/src/easyconfig/expansion/__init__.py @@ -0,0 +1 @@ +from .expand import expand_obj diff --git a/src/easyconfig/expansion/expand.py b/src/easyconfig/expansion/expand.py new file mode 100644 index 0000000..46b3c9d --- /dev/null +++ b/src/easyconfig/expansion/expand.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import re + +from .load_file import is_path, read_file_contents +from .load_var import read_env_var +from .location import ExpansionLocation + +RE_REPLACE = re.compile(r''' + (?.*?[^$])? + (?!\$)} +''', re.VERBOSE) + + +RE_ESCAPED = re.compile(RE_REPLACE.pattern.replace(r'(? tuple[str, str]: + + if is_path(key): + name, value = read_file_contents(key, loc) + else: + name, value = read_env_var(key, loc) + + if value is None: + value = '' + return name, value + + +def expand_text(text: str, /, loc: ExpansionLocation): + if not isinstance(text, str): + return text + + if '$' not in text: + return text + + pos_start = 0 + + while m := RE_REPLACE.search(text, pos_start): + m_start = m.start() + m_end = m.end() + full_match = m_start == 0 and m_end == len(text) + + raw_value = m.group('value') + raw_value = raw_value.replace('$}', '}') + + name, value = read_value(raw_value, full_match, loc=loc) + + # value is valid + value = expand_text(value, loc=loc.expand_value(name)) + text = text[:m_start] + value + text[m_end:] + + # escaped expansion + if '$$' in text: + text = RE_ESCAPED.sub(r'${\g}', text) + return text + + +def expand_obj(obj, loc: ExpansionLocation | None = None): + if loc is None: + loc = ExpansionLocation((), ()) + + if isinstance(obj, dict): + for key, value in obj.items(): + obj[key] = expand_obj(value, loc.process_obj(str(key))) + return obj + elif isinstance(obj, (list, tuple)): + for i, value in enumerate(obj): + obj[i] = expand_obj(value, loc.process_obj(f'[{i:d}]')) + return obj + else: + return expand_text(obj, loc) diff --git a/src/easyconfig/expansion/load_file.py b/src/easyconfig/expansion/load_file.py new file mode 100644 index 0000000..f2e4c22 --- /dev/null +++ b/src/easyconfig/expansion/load_file.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import re +from pathlib import Path +from string import ascii_letters + +from .location import ExpansionLocation, log + +# https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file +RE_WIN_PATH = re.compile( + r''' + (?: + [a-zA-Z]:[/\\] # Drive C:\path\file + | + (?:\\\\|//) # UNC: \\server\share\path\file + ) + [^''' + re.escape('<>:"|?*') + ']*', + re.VERBOSE +) + + +def is_path(txt: str) -> bool: + # absolute unix path + if txt.startswith('/'): + return True + + # absolute windows path + if RE_WIN_PATH.match(txt): + return True + + return False + + +def parse_path_key(txt: str) -> tuple[str, str | None]: + + parts = txt.split(':', 1) + + # we have no default + if len(parts) == 1: + return parts[0], None + + path, default = parts + + # check if the first entry is a windows path with a drive designator because + # then we have to merge them back together + if path not in ascii_letters: + return path, default + + drive = path + parts = default.split(':', 1) + parts[0] = drive + ':' + parts[0] + + if len(parts) == 1: + return parts[0], None + + path, default = parts + return path, default + + +def read_file(name: str) -> str: + # do it like this so we can patch the doc + return Path(name).read_text().rstrip() + + +def read_file_contents(key: str, loc: ExpansionLocation) -> tuple[str, str | None]: + name, default = parse_path_key(key) + + try: + return name, read_file(name) + except Exception as e: + msg = f'Error while reading from file "{name:s}": ({e}) {loc.location_str()}' + + if default is not None: + log.warning(msg) + return name, default + + log.error(msg) + return name, None diff --git a/src/easyconfig/expansion/load_var.py b/src/easyconfig/expansion/load_var.py new file mode 100644 index 0000000..a95caa1 --- /dev/null +++ b/src/easyconfig/expansion/load_var.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from os import environ + +from .location import ExpansionLocation, log + + +def parse_env_key(key: str) -> tuple[str, str | None]: + parts = key.split(':', 1) + if len(parts) == 1: + return parts[0], None + + key, default = parts + return key, default + + +def read_env_var(key: str, loc: ExpansionLocation) -> tuple[str, str | None]: + name, default = parse_env_key(key) + + if (value := environ.get(name)) is not None: + return name, value + + msg = f'Environment variable "{name:s}" is not set or empty! {loc.location_str()}' + + if default is not None: + log.warning(msg) + return name, default + + log.error(msg) + return name, None diff --git a/src/easyconfig/expansion/location.py b/src/easyconfig/expansion/location.py new file mode 100644 index 0000000..48e0da9 --- /dev/null +++ b/src/easyconfig/expansion/location.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from easyconfig.errors.errors import CyclicEnvironmentVariableReferenceError + +log = logging.getLogger('easyconfig.expansion') + + +@dataclass(frozen=True) +class ExpansionLocation: + loc: tuple[str, ...] # location in the yaml + stack: tuple[str, ...] # stack for expansion of values + + def expand_value(self, name: str): + # value is valid + new_stack = self.stack + (name, ) + if name in self.stack: + raise CyclicEnvironmentVariableReferenceError( + f'Cyclic environment variable reference: {" -> ".join(new_stack):s} {self.location_str()}' + ) + return ExpansionLocation(loc=self.loc, stack=new_stack) + + def process_obj(self, name: str): + return ExpansionLocation(loc=self.loc + (name, ), stack=()) + + def location_str(self) -> str: + loc = ('__root__', *self.loc) + return f'(at {".".join(loc)})' diff --git a/src/easyconfig/models/app.py b/src/easyconfig/models/app.py index 63531b5..0113074 100644 --- a/src/easyconfig/models/app.py +++ b/src/easyconfig/models/app.py @@ -14,20 +14,19 @@ def set_file_path(self, path: Union[Path, str]): """ pass - def load_config_dict(self, cfg: dict): - """Load values from a dictionary + def load_config_dict(self, cfg: dict, /, expansion: bool = True): + """Load the configuration from a dictionary - :param cfg: dictionary containing all the keys - :returns: True if config changed else False + :param cfg: config dict which will be loaded + :param expansion: Expand ${...} in strings """ pass - def load_config_file(self, path: Union[Path, str] = None): - """Load values from the configuration file. If the file doesn't exist it will be created. - Missing required config entries will also be created. + def load_config_file(self, path: Union[Path, str] = None, expansion: bool = True): + """Load configuration from a yaml file. If the file does not exist a default file will be created - :param path: if not already set a path instance to the config file - :returns: True if config changed else False + :param path: Path to file + :param expansion: Expand ${...} in strings """ pass diff --git a/src/easyconfig/models/convenience.py b/src/easyconfig/models/convenience.py index 1e7bcb4..eea641c 100644 --- a/src/easyconfig/models/convenience.py +++ b/src/easyconfig/models/convenience.py @@ -1,31 +1,21 @@ import pydantic +import pydantic_settings +from pydantic import ConfigDict from easyconfig.models import AppConfigMixin, ConfigMixin class BaseModel(pydantic.BaseModel, ConfigMixin): - - class Config: - extra = pydantic.Extra.forbid - validate_all = True + model_config = ConfigDict(extra='forbid', validate_default=True) class AppBaseModel(pydantic.BaseModel, AppConfigMixin): + model_config = ConfigDict(extra='forbid', validate_default=True) - class Config: - extra = pydantic.Extra.forbid - validate_all = True - - -class BaseSettings(pydantic.BaseSettings, ConfigMixin): - - class Config: - extra = pydantic.Extra.forbid - validate_all = True +class BaseSettings(pydantic_settings.BaseSettings, ConfigMixin): + model_config = ConfigDict(extra='forbid', validate_default=True) -class AppBaseSettings(pydantic.BaseSettings, AppConfigMixin): - class Config: - extra = pydantic.Extra.forbid - validate_all = True +class AppBaseSettings(pydantic_settings.BaseSettings, AppConfigMixin): + model_config = ConfigDict(extra='forbid', validate_default=True) diff --git a/src/easyconfig/yaml/from_model.py b/src/easyconfig/yaml/from_model.py index a5bac2f..5cf9a5a 100644 --- a/src/easyconfig/yaml/from_model.py +++ b/src/easyconfig/yaml/from_model.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum from pydantic import BaseModel @@ -8,7 +10,7 @@ NoneType = type(None) -def _get_yaml_value(obj, parent_model: BaseModel, skip_none=True): +def _get_yaml_value(obj, parent_model: BaseModel, *, skip_none=True, obj_name: str | None = None): if obj is None: return None @@ -39,30 +41,29 @@ def _get_yaml_value(obj, parent_model: BaseModel, skip_none=True): ret[yaml_key] = _get_yaml_value(value, parent_model=parent_model, skip_none=skip_none) return ret - # YAML can't serialize all data pydantic types natively, so we use the json serializer of the model + # YAML can't serialize all data pydantic types natively, so we use the serializer of the model # This works since a valid json is always a valid YAML. It's not nice but it's something! - model_cfg = parent_model.__config__ - str_val = model_cfg.json_dumps(obj, default=parent_model.__json_encoder__) - return model_cfg.json_loads(str_val) + dump = parent_model.model_dump(mode='json', include={obj_name}) + return dump[obj_name] def cmap_from_model(model: BaseModel, skip_none=True) -> CommentedMap: cmap = CommentedMap() - for obj_key, field in model.__fields__.items(): - value = getattr(model, obj_key, MISSING) + for obj_name, field in model.model_fields.items(): + value = getattr(model, obj_name, MISSING) if value is MISSING or (skip_none and value is None): continue - field_info = field.field_info - yaml_key = field.alias - description = field_info.description + if yaml_key is None: + yaml_key = obj_name + description = field.description - if not field_info.extra.get(ARG_NAME_IN_FILE, True): + if (extra_kwargs := field.json_schema_extra) is not None and not extra_kwargs.get(ARG_NAME_IN_FILE, True): continue # get yaml representation - cmap[yaml_key] = _get_yaml_value(value, parent_model=model) + cmap[yaml_key] = _get_yaml_value(value, parent_model=model, obj_name=obj_name) if not description: continue diff --git a/tests/helper/my_path.py b/tests/helper/my_path.py index 1228833..ea5a9a7 100644 --- a/tests/helper/my_path.py +++ b/tests/helper/my_path.py @@ -9,7 +9,7 @@ class Path(_path_type): _flavour = _path_type._flavour def __init__(self, *args, does_exist: bool = True, initial_value: Optional[str] = None, **kwargs): - super(Path, self).__init__() + super().__init__() if hasattr(Path, '_from_parts'): Path._from_parts(args) @@ -27,7 +27,7 @@ def __init__(self, *args, does_exist: bool = True, initial_value: Optional[str] self._create_buffer(initial_value) def __new__(cls, *args, **kwargs): - return super(Path, cls).__new__(cls, *args) + return super().__new__(cls, *args) def _create_buffer(self, initial_value: Optional[str] = None): self.contents = StringIO(initial_value) diff --git a/tests/test_expansion/__init__.py b/tests/test_expansion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_expansion/conftest.py b/tests/test_expansion/conftest.py new file mode 100644 index 0000000..000b67a --- /dev/null +++ b/tests/test_expansion/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from easyconfig.expansion import load_var as var_module + + +@pytest.fixture() +def envs(monkeypatch): + env_dict = {} + monkeypatch.setattr(var_module, 'environ', env_dict) + return env_dict diff --git a/tests/test_expansion/test_env.py b/tests/test_expansion/test_env.py new file mode 100644 index 0000000..941ef09 --- /dev/null +++ b/tests/test_expansion/test_env.py @@ -0,0 +1,42 @@ +import logging + +from easyconfig.expansion.load_var import parse_env_key, read_env_var +from easyconfig.expansion.location import ExpansionLocation, log + + +def test_parse_env_key(): + assert parse_env_key('NAME') == ('NAME', None) + assert parse_env_key('NAME:DEFAULT') == ('NAME', 'DEFAULT') + assert parse_env_key('NAME:DEFAULT:MULTIPLE:COLON') == ('NAME', 'DEFAULT:MULTIPLE:COLON') + + assert parse_env_key('NAME:') == ('NAME', '') + assert parse_env_key(':DEFAULT') == ('', 'DEFAULT') + + +def test_read_env_existing(envs: dict): + envs.update({'NAME': 'asdf'}) + + loc = ExpansionLocation(loc=(), stack=()) + assert read_env_var('NAME', loc=loc) == ('NAME', 'asdf') + assert read_env_var('NAME:DEFAULT', loc=loc) == ('NAME', 'asdf') + + +def test_read_file_missing(caplog, envs): + caplog.set_level(logging.DEBUG) + loc = ExpansionLocation(loc=('key_1', ), stack=()) + + assert read_env_var('DOES_NOT_EXIST', loc=loc) == ('DOES_NOT_EXIST', None) + [[log_name, log_lvl, log_msg]] = caplog.record_tuples + + assert log_name == log.name + assert log_lvl == logging.ERROR + assert log_msg == 'Environment variable "DOES_NOT_EXIST" is not set or empty! (at __root__.key_1)' + + caplog.clear() + + assert read_env_var('DOES_NOT_EXIST:MY_DEFAULT', loc=loc) == ('DOES_NOT_EXIST', 'MY_DEFAULT') + [[log_name, log_lvl, log_msg]] = caplog.record_tuples + + assert log_name == log.name + assert log_lvl == logging.WARNING + assert log_msg == 'Environment variable "DOES_NOT_EXIST" is not set or empty! (at __root__.key_1)' diff --git a/tests/test_expansion/test_expansion.py b/tests/test_expansion/test_expansion.py new file mode 100644 index 0000000..21d9bd8 --- /dev/null +++ b/tests/test_expansion/test_expansion.py @@ -0,0 +1,71 @@ +import pytest + +from easyconfig.errors.errors import CyclicEnvironmentVariableReferenceError +from easyconfig.expansion.expand import expand_obj, expand_text, RE_REPLACE +from easyconfig.expansion.location import ExpansionLocation + + +def test_regex(): + assert RE_REPLACE.fullmatch('${}') + assert RE_REPLACE.fullmatch('${asdf}') + + assert not RE_REPLACE.search('$${}') + assert not RE_REPLACE.search('$${asdf}') + + assert RE_REPLACE.search('${asdf${asdf}').group(1) == 'asdf${asdf' + assert RE_REPLACE.search('${${}').group(1) == '${' + + +def test_load_env(envs: dict): + envs.update({ + 'NAME': 'asdf', 'RECURSE': 'Name: ${NAME}', + 'TEST_$_DOLLAR': 'DOLLAR_WORKS', + 'TEST_}_CURLY': 'CURLY_WORKS' + }) + + loc = ExpansionLocation((), ()) + + assert expand_text('${NAME}', loc) == 'asdf' + assert expand_text('${NOT_EXIST}', loc) == '' + assert expand_text('${NOT_EXIST:DEFAULT}', loc) == 'DEFAULT' + + assert expand_text('Test ${RECURSE}', loc) == 'Test Name: asdf' + + assert expand_text('Test ${RECURSE}', loc) == 'Test Name: asdf' + + assert expand_text('${TEST_$_DOLLAR}', loc) == 'DOLLAR_WORKS' + + # escape expansion + assert expand_text('$${}', loc) == '${}' + assert expand_text('$${NAME}', loc) == '${NAME}' + assert expand_text('$${:ASDF}', loc) == '${:ASDF}' + assert expand_text('$${NAME:DEFAULT}', loc) == '${NAME:DEFAULT}' + + # escape closing bracket + assert expand_text('${MISSING:DEFAULT$}_}', loc) == 'DEFAULT}_' + assert expand_text('${TEST_$}_CURLY}', loc) == 'CURLY_WORKS' + + +def test_env_cyclic_reference(envs: dict): + envs.update({'NAME': '${SELF}', 'SELF': 'Name: ${SELF}'}) + + with pytest.raises(CyclicEnvironmentVariableReferenceError) as e: + assert expand_text('Test self: ${NAME}', loc=ExpansionLocation(loc=('a', ), stack=())) == 'asdf' + + assert str(e.value) == 'Cyclic environment variable reference: NAME -> SELF -> SELF (at __root__.a)' + + +def test_expansion(envs: dict): + envs.update({'NAME': 'ASDF'}) + + obj = { + 'a': {'b': ['${NAME}']}, + 'b': '${MISSING:DEFAULT}' + } + + expand_obj(obj) + + assert obj == { + 'a': {'b': ['ASDF']}, + 'b': 'DEFAULT' + } diff --git a/tests/test_expansion/test_file.py b/tests/test_expansion/test_file.py new file mode 100644 index 0000000..dd5a6ca --- /dev/null +++ b/tests/test_expansion/test_file.py @@ -0,0 +1,81 @@ +import logging + +import pytest + +from easyconfig.expansion.load_file import is_path, parse_path_key, read_file_contents +from easyconfig.expansion.location import ExpansionLocation, log + + +@pytest.mark.parametrize( + 'txt', (pytest.param('c:/path/file', id='p1'), pytest.param('//server/share/path/file', id='p2')) +) +def test_is_path_win(txt: str): + assert is_path(txt) + assert is_path(txt.upper()) + + txt = txt.replace('/', '\\') + assert is_path(txt) + assert is_path(txt.upper()) + + +def test_is_path(): + # unix path + assert is_path('/asdf') + assert is_path('/asdf.txt') + + # we require absolute paths + assert not is_path('asdf.txt') + + +def test_parse_path(): + assert parse_path_key('c:/path/file') == ('c:/path/file', None) + assert parse_path_key('//server/share/path/file') == ('//server/share/path/file', None) + assert parse_path_key('/asdf') == ('/asdf', None) + + assert parse_path_key('c:/path/file:MY_DEFAULT') == ('c:/path/file', 'MY_DEFAULT') + assert parse_path_key('//server/share/path/file:MY_DEFAULT') == ('//server/share/path/file', 'MY_DEFAULT') + assert parse_path_key('/asdf:MY_DEFAULT') == ('/asdf', 'MY_DEFAULT') + + assert parse_path_key('c:/path/file:MY_DEFAULT:WITH:MORE') == ('c:/path/file', 'MY_DEFAULT:WITH:MORE') + assert parse_path_key('//server/share/path/file:MY_DEFAULT:WITH:MORE') == ( + '//server/share/path/file', 'MY_DEFAULT:WITH:MORE') + assert parse_path_key('/asdf:MY_DEFAULT:WITH:MORE') == ('/asdf', 'MY_DEFAULT:WITH:MORE') + + +def test_read_file_existing(caplog, tmp_path): + caplog.set_level(logging.DEBUG) + loc = ExpansionLocation(loc=(), stack=()) + + file = tmp_path / 'tmp.txt' + file.write_text('asdf asdf\n\n') + + name, value = read_file_contents(str(file), loc=loc) + assert name == str(file) + assert value == 'asdf asdf' + + name, value = read_file_contents(f'{file.as_posix()}:DEFAULT', loc=loc) + assert name == file.as_posix() + assert value == 'asdf asdf' + + +def test_read_file_missing(caplog): + caplog.set_level(logging.DEBUG) + loc = ExpansionLocation(loc=('key_1', ), stack=()) + + assert read_file_contents('/does/not/exist', loc=loc) == ('/does/not/exist', None) + [[log_name, log_lvl, log_msg]] = caplog.record_tuples + + assert log_name == log.name + assert log_lvl == logging.ERROR + assert log_msg.startswith('Error while reading from file "/does/not/exist": ') + assert log_msg.endswith(' (at __root__.key_1)') + + caplog.clear() + + assert read_file_contents('/does/not/exist:MY_DEFAULT', loc=loc) == ('/does/not/exist', 'MY_DEFAULT') + [[log_name, log_lvl, log_msg]] = caplog.record_tuples + + assert log_name == log.name + assert log_lvl == logging.WARNING + assert log_msg.startswith('Error while reading from file "/does/not/exist": ') + assert log_msg.endswith(' (at __root__.key_1)') diff --git a/tests/test_implementation/test_class_signatures.py b/tests/test_implementation/test_class_signatures.py index df80a23..dbcaeff 100644 --- a/tests/test_implementation/test_class_signatures.py +++ b/tests/test_implementation/test_class_signatures.py @@ -16,10 +16,11 @@ def test_signatures_match(mixin_cls, impl_cls): for name, value in inspect.getmembers(mixin_cls): if name.startswith('_') or name in ('from_model', ): continue - impl = getattr(impl_cls, name) target_spec = inspect.getfullargspec(value) current_spec = inspect.getfullargspec(impl) assert current_spec == target_spec + + assert inspect.getdoc(value) == inspect.getdoc(impl) diff --git a/tests/test_pydantic/test_field_access.py b/tests/test_pydantic/test_field_access.py index f7da1cf..12be791 100644 --- a/tests/test_pydantic/test_field_access.py +++ b/tests/test_pydantic/test_field_access.py @@ -1,7 +1,7 @@ from typing import List from pydantic import BaseModel, Field, PrivateAttr -from pydantic.fields import ModelField +from pydantic.fields import FieldInfo class MyDataSet: @@ -21,17 +21,17 @@ def __init__(self, **data): class UserModel(MyModel): val_int: int - val_str = 'test' + val_str: str = 'test' val_f: List[str] = Field(description='This key does this') def test_get_model_desc(): - assert list(UserModel.__fields__.keys()) == ['val_int', 'val_f', 'val_str', ] + assert list(UserModel.model_fields.keys()) == ['val_int', 'val_str', 'val_f',] - for field in UserModel.__fields__.values(): - assert isinstance(field, ModelField) + for field in UserModel.model_fields.values(): + assert isinstance(field, FieldInfo) - assert UserModel.__fields__['val_f'].field_info.description == 'This key does this' + assert UserModel.model_fields['val_f'].description == 'This key does this' def test_mutate(capsys): @@ -49,11 +49,11 @@ def test_mutate(capsys): setattr(m, 'val_int', 88) # noqa: B010 assert m.val_int == 88 - for name in m.__fields__: + for name in m.model_fields: print(f'{name}: {getattr(m, name)}') captured = capsys.readouterr() - assert captured.out == "val_int: 88\nval_f: ['asdf']\nval_str: test\n" + assert captured.out == "val_int: 88\nval_str: test\nval_f: ['asdf']\n" def test_nested_models(): @@ -69,8 +69,8 @@ class ParentModel(BaseModel): obj = ParentModel() assert obj.b.c_b == 3 - assert 'a' in obj.__fields__ - assert 'b' in obj.__fields__ + assert 'a' in obj.model_fields + assert 'b' in obj.model_fields def test_env_access(): @@ -85,7 +85,7 @@ class ParentModel(BaseModel): obj = ParentModel() - assert obj.b.__fields__['c_a'].field_info.extra == {'env': 'my_env_var'} + assert obj.b.model_fields['c_a'].json_schema_extra == {'env': 'my_env_var'} def test_private_attr(): @@ -97,5 +97,5 @@ class SimpleModel(BaseModel): SimpleModel() - assert list(SimpleModel.__fields__.keys()) == ['a', ] + assert list(SimpleModel.model_fields.keys()) == ['a', ] assert list(SimpleModel.__private_attributes__.keys()) == ['_priv2', '_priv3'] diff --git a/tests/yaml/test_yaml_value.py b/tests/yaml/test_yaml_value.py index cd4ea24..d98a7a6 100644 --- a/tests/yaml/test_yaml_value.py +++ b/tests/yaml/test_yaml_value.py @@ -3,18 +3,15 @@ from pydantic import AnyHttpUrl from pydantic import BaseModel as _BaseModel -from pydantic import ByteSize, condate, confloat, conint, conlist, conset, \ - constr, Extra, NegativeFloat, StrictBool, StrictBytes, StrictInt +from pydantic import ByteSize, condate, ConfigDict, confloat, conint, conlist, \ + conset, constr, NegativeFloat, StrictBool, StrictBytes, StrictInt from easyconfig.yaml import CommentedMap, CommentedSeq from easyconfig.yaml.from_model import _get_yaml_value class BaseModel(_BaseModel): - class Config: - extra = Extra.forbid - validate_all = True - validate_assignment = True + model_config = ConfigDict(validate_assignment=True, validate_default=True, extra='forbid') def cmp_value(obj, target): @@ -52,7 +49,7 @@ class ConstrainedModel(BaseModel): con_int: conint(ge=10) = 11 con_str: constr(strip_whitespace=True) = ' asdf ' - con_list: conlist(str) = [1] + con_list: conlist(str) = ['1'] con_set: conset(bool) = {1} con_date: condate(ge=datetime.date(2023, 1, 1)) = datetime.date(2023, 1, 2) @@ -66,10 +63,10 @@ class ConstrainedModel(BaseModel): cmp_value(_get_yaml_value(m.con_int, m), 11) cmp_value(_get_yaml_value(m.con_str, m), 'asdf') - cmp_value(_get_yaml_value(m.con_list, m), CommentedSeq(['1'])) - cmp_value(_get_yaml_value(m.con_set, m), CommentedSeq([True])) + cmp_value(_get_yaml_value(m.con_list, m, obj_name='con_list'), CommentedSeq(['1'])) + cmp_value(_get_yaml_value(m.con_set, m, obj_name='con_list'), CommentedSeq([True])) - cmp_value(_get_yaml_value(m.con_date, m), '2023-01-02') # yaml can't natively serialize dates + cmp_value(_get_yaml_value(m.con_date, m, obj_name='con_date'), '2023-01-02') # yaml can't natively serialize dates def test_strict_types(): @@ -101,11 +98,11 @@ class SimpleModel(BaseModel): size_raw: ByteSize = 100 size_str: ByteSize = '10kb' size_obj: ByteSize = ByteSize(50) - url: AnyHttpUrl = 'http://test.de' + url: AnyHttpUrl = 'http://test.de/asdf' m = SimpleModel() assert _get_yaml_value(m.size_raw, m) == 100 assert _get_yaml_value(m.size_str, m) == 10_000 assert _get_yaml_value(m.size_obj, m) == 50 - assert _get_yaml_value(m.url, m) == 'http://test.de' + assert _get_yaml_value(m.url, m, obj_name='url') == 'http://test.de/asdf'