pax_global_header00006660000000000000000000000064151605543120014514gustar00rootroot0000000000000052 comment=a98f9d6f8efd875fbd158965793334f5b25f9eeb explosion-confection-a98f9d6/000077500000000000000000000000001516055431200163155ustar00rootroot00000000000000explosion-confection-a98f9d6/.github/000077500000000000000000000000001516055431200176555ustar00rootroot00000000000000explosion-confection-a98f9d6/.github/workflows/000077500000000000000000000000001516055431200217125ustar00rootroot00000000000000explosion-confection-a98f9d6/.github/workflows/cibuildwheel.yml000066400000000000000000000014051516055431200250750ustar00rootroot00000000000000name: Build on: push: tags: # ytf did they invent their own syntax that's almost regex? # ** matches 'zero or more of any character' - 'release-v[0-9]+.[0-9]+.[0-9]+**' - 'prerelease-v[0-9]+.[0-9]+.[0-9]+**' workflow_dispatch: # allows you to trigger manually permissions: {} jobs: build_wheels: uses: explosion/gha-cibuildwheel/.github/workflows/cibuildwheel.yml@2c98f757f13d112cf73fcf4b627249f1fffb5aae # main permissions: contents: write actions: read with: wheel-name-pattern: "confection-*.whl" pure-python: true create-release: ${{ startsWith(github.ref, 'refs/tags/release-') || startsWith(github.ref, 'refs/tags/prerelease-') }} secrets: gh-token: ${{ secrets.GITHUB_TOKEN }} explosion-confection-a98f9d6/.github/workflows/tests.yml000066400000000000000000000037671516055431200236140ustar00rootroot00000000000000name: tests on: push: branches: [main] pull_request: branches: ["*"] workflow_dispatch: permissions: {} concurrency: group: tests-${{ github.ref }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.13" - run: pip install ruff pyright - run: ruff check confection - run: ruff format confection --check - run: pyright confection tests: name: Test strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python_version: ["3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: - name: Check out repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Configure Python version uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python_version }} - name: Build sdist run: | python -m pip install -U build pip setuptools python -m pip install -U -r requirements.txt python -m build --sdist - name: Delete source directory run: rm -rf confection shell: bash - name: Uninstall all packages run: | python -m pip freeze > installed.txt python -m pip uninstall -y -r installed.txt - name: Install from sdist run: | SDIST=$(python -c "import os;print(os.listdir('./dist')[-1])" 2>&1) python -m pip install dist/$SDIST shell: bash - name: Test import run: python -c "import confection" -Werror - name: Install test requirements run: | python -m pip install -U -r requirements.txt - name: Run tests run: | python -m pytest tests/ explosion-confection-a98f9d6/.gitignore000066400000000000000000000015461516055431200203130ustar00rootroot00000000000000tmp/ .pytest_cache .vscode .mypy_cache .prettierrc .python-version # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python .env/ env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ #Ipython Notebook .ipynb_checkpoints # Pycharm project files *.idea explosion-confection-a98f9d6/LICENSE000066400000000000000000000020611516055431200173210ustar00rootroot00000000000000MIT License Copyright (c) 2019 ExplosionAI GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. explosion-confection-a98f9d6/MANIFEST.in000066400000000000000000000000541516055431200200520ustar00rootroot00000000000000include LICENSE include confection/py.typed explosion-confection-a98f9d6/README.md000066400000000000000000000435731516055431200176100ustar00rootroot00000000000000 # Confection: The sweetest config system for Python `confection` :candy: is a lightweight library that offers a **configuration system** letting you conveniently describe arbitrary trees of objects. Configuration is a huge challenge for machine-learning code because you may want to expose almost any detail of any function as a hyperparameter. The setting you want to expose might be arbitrarily far down in your call stack, so it might need to pass all the way through the CLI or REST API, through any number of intermediate functions, affecting the interface of everything along the way. And then once those settings are added, they become hard to remove later. Default values also become hard to change without breaking backwards compatibility. To solve this problem, `confection` offers a config system that lets you easily describe arbitrary trees of objects. The objects can be created via function calls you register using a simple decorator syntax. You can even version the functions you create, allowing you to make improvements without breaking backwards compatibility. The most similar config system we’re aware of is [Gin](https://github.com/google/gin-config), which uses a similar syntax, and also allows you to link the configuration system to functions in your code using a decorator. `confection`'s config system is simpler and emphasizes a different workflow via a subset of Gin’s functionality. [![tests](https://github.com/explosion/confection/actions/workflows/tests.yml/badge.svg)](https://github.com/explosion/confection/actions/workflows/tests.yml) [![Current Release Version](https://img.shields.io/github/v/release/explosion/confection.svg?style=flat-square&include_prereleases&logo=github)](https://github.com/explosion/confection/releases) [![pypi Version](https://img.shields.io/pypi/v/confection.svg?style=flat-square&logo=pypi&logoColor=white)](https://pypi.org/project/confection/) [![conda Version](https://img.shields.io/conda/vn/conda-forge/confection.svg?style=flat-square&logo=conda-forge&logoColor=white)](https://anaconda.org/conda-forge/confection) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square)](https://github.com/ambv/black) ## ⏳ Installation ```bash pip install confection ``` ```bash conda install -c conda-forge confection ``` ## 👩‍💻 Usage The configuration system parses a `.cfg` file like ```ini [training] patience = 10 dropout = 0.2 use_vectors = false [training.logging] level = "INFO" [nlp] # This uses the value of training.use_vectors use_vectors = ${training.use_vectors} lang = "en" ``` and resolves it to a `Dict`: ```json { "training": { "patience": 10, "dropout": 0.2, "use_vectors": false, "logging": { "level": "INFO" } }, "nlp": { "use_vectors": false, "lang": "en" } } ``` The config is divided into sections, with the section name in square brackets – for example, `[training]`. Within the sections, config values can be assigned to keys using `=`. Values can also be referenced from other sections using the dot notation and placeholders indicated by the dollar sign and curly braces. For example, `${training.use_vectors}` will receive the value of use_vectors in the training block. This is useful for settings that are shared across components. The config format has three main differences from Python’s built-in `configparser`: 1. JSON-formatted values. `confection` passes all values through `json.loads` to interpret them. You can use atomic values like strings, floats, integers or booleans, or you can use complex objects such as lists or maps. 2. Structured sections. `confection` uses a dot notation to build nested sections. If you have a section named `[section.subsection]`, `confection` will parse that into a nested structure, placing subsection within section. 3. References to registry functions. If a key starts with `@`, `confection` will interpret its value as the name of a function registry, load the function registered for that name and pass in the rest of the block as arguments. If type hints are available on the function, the argument values (and return value of the function) will be validated against them. This lets you express complex configurations, like a training pipeline where `batch_size` is populated by a function that yields floats. There’s no pre-defined scheme you have to follow; how you set up the top-level sections is up to you. At the end of it, you’ll receive a dictionary with the values that you can use in your script – whether it’s complete initialized functions, or just basic settings. For instance, let’s say you want to define a new optimizer. You'd define its arguments in `config.cfg` like so: ```ini [optimizer] @optimizers = "my_cool_optimizer.v1" learn_rate = 0.001 gamma = 1e-8 ``` To load and parse this configuration using a `catalogue` registry (install [`catalogue`](https://github.com/explosion/catalogue) separately): ```python import dataclasses from typing import Union, Iterable import catalogue from confection import registry, Config # Create a new registry. registry.optimizers = catalogue.create("confection", "optimizers", entry_points=False) # Define a dummy optimizer class. @dataclasses.dataclass class MyCoolOptimizer: learn_rate: float gamma: float @registry.optimizers.register("my_cool_optimizer.v1") def make_my_optimizer(learn_rate: Union[float, Iterable[float]], gamma: float): return MyCoolOptimizer(learn_rate, gamma) # Load the config file from disk, resolve it and fetch the instantiated optimizer object. config = Config().from_disk("./config.cfg") resolved = registry.resolve(config) optimizer = resolved["optimizer"] # MyCoolOptimizer(learn_rate=0.001, gamma=1e-08) ``` > ⚠️ Caution: Type-checkers such as `mypy` will mark adding new attributes to `registry` this way - i. e. > `registry.new_attr = ...` - as errors. This is because a new attribute is added to the class after initialization. If > you are using typecheckers, you can either ignore this (e. g. with `# type: ignore` for `mypy`) or use a typesafe > alternative: instead of `registry.new_attr = ...`, use `setattr(registry, "new_attr", ...)`. Under the hood, `confection` will look up the `"my_cool_optimizer.v1"` function in the "optimizers" registry and then call it with the arguments `learn_rate` and `gamma`. If the function has type annotations, it will also validate the input. For instance, if `learn_rate` is annotated as a float and the config defines a string, `confection` will raise an error. The Thinc documentation offers further information on the configuration system: - [recursive blocks](https://thinc.ai/docs/usage-config#registry-recursive) - [defining variable positional arguments](https://thinc.ai/docs/usage-config#registries-args) - [using interpolation](https://thinc.ai/docs/usage-config#config-interpolation) - [using custom registries](https://thinc.ai/docs/usage-config#registries-custom) - [advanced type annotations with Pydantic](https://thinc.ai/docs/usage-config#advanced-types) - [using base schemas](https://thinc.ai/docs/usage-config#advanced-types-base-schema) - [filling a configuration with defaults](https://thinc.ai/docs/usage-config#advanced-types-fill-defaults) ## 🎛 API ### class `Config` This class holds the model and training [configuration](https://thinc.ai/docs/usage-config) and can load and save the INI-style configuration format from/to a string, file or bytes. The `Config` class is a subclass of `dict` and uses Python’s `ConfigParser` under the hood. #### method `Config.__init__` Initialize a new `Config` object with optional data. ```python from confection import Config config = Config({"training": {"patience": 10, "dropout": 0.2}}) ``` | Argument | Type | Description | | ----------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `Optional[Union[Dict[str, Any], Config]]` | Optional data to initialize the config with. | | `section_order` | `Optional[List[str]]` | Top-level section names, in order, used to sort the saved and loaded config. All other sections will be sorted alphabetically. | | `is_interpolated` | `Optional[bool]` | Whether the config is interpolated or whether it contains variables. Read from the `data` if it’s an instance of `Config` and otherwise defaults to `True`. | #### method `Config.from_str` Load the config from a string. ```python from confection import Config config_str = """ [training] patience = 10 dropout = 0.2 """ config = Config().from_str(config_str) print(config["training"]) # {'patience': 10, 'dropout': 0.2}} ``` | Argument | Type | Description | | ------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- | | `text` | `str` | The string config to load. | | `interpolate` | `bool` | Whether to interpolate variables like `${section.key}`. Defaults to `True`. | | `overrides` | `Dict[str, Any]` | Overrides for values and sections. Keys are provided in dot notation, e.g. `"training.dropout"` mapped to the value. | | **RETURNS** | `Config` | The loaded config. | #### method `Config.to_str` Load the config from a string. ```python from confection import Config config = Config({"training": {"patience": 10, "dropout": 0.2}}) print(config.to_str()) # '[training]\npatience = 10\n\ndropout = 0.2' ``` | Argument | Type | Description | | ------------- | ------ | --------------------------------------------------------------------------- | | `interpolate` | `bool` | Whether to interpolate variables like `${section.key}`. Defaults to `True`. | | **RETURNS** | `str` | The string config. | #### method `Config.to_bytes` Serialize the config to a byte string. ```python from confection import Config config = Config({"training": {"patience": 10, "dropout": 0.2}}) config_bytes = config.to_bytes() print(config_bytes) # b'[training]\npatience = 10\n\ndropout = 0.2' ``` | Argument | Type | Description | | ------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- | | `interpolate` | `bool` | Whether to interpolate variables like `${section.key}`. Defaults to `True`. | | `overrides` | `Dict[str, Any]` | Overrides for values and sections. Keys are provided in dot notation, e.g. `"training.dropout"` mapped to the value. | | **RETURNS** | `str` | The serialized config. | #### method `Config.from_bytes` Load the config from a byte string. ```python from confection import Config config = Config({"training": {"patience": 10, "dropout": 0.2}}) config_bytes = config.to_bytes() new_config = Config().from_bytes(config_bytes) ``` | Argument | Type | Description | | ------------- | -------- | --------------------------------------------------------------------------- | | `bytes_data` | `bool` | The data to load. | | `interpolate` | `bool` | Whether to interpolate variables like `${section.key}`. Defaults to `True`. | | **RETURNS** | `Config` | The loaded config. | #### method `Config.to_disk` Serialize the config to a file. ```python from confection import Config config = Config({"training": {"patience": 10, "dropout": 0.2}}) config.to_disk("./config.cfg") ``` | Argument | Type | Description | | ------------- | ------------------ | --------------------------------------------------------------------------- | | `path` | `Union[Path, str]` | The file path. | | `interpolate` | `bool` | Whether to interpolate variables like `${section.key}`. Defaults to `True`. | #### method `Config.from_disk` Load the config from a file. ```python from confection import Config config = Config({"training": {"patience": 10, "dropout": 0.2}}) config.to_disk("./config.cfg") new_config = Config().from_disk("./config.cfg") ``` | Argument | Type | Description | | ------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------- | | `path` | `Union[Path, str]` | The file path. | | `interpolate` | `bool` | Whether to interpolate variables like `${section.key}`. Defaults to `True`. | | `overrides` | `Dict[str, Any]` | Overrides for values and sections. Keys are provided in dot notation, e.g. `"training.dropout"` mapped to the value. | | **RETURNS** | `Config` | The loaded config. | #### method `Config.copy` Deep-copy the config. | Argument | Type | Description | | ----------- | -------- | ------------------ | | **RETURNS** | `Config` | The copied config. | #### method `Config.interpolate` Interpolate variables like `${section.value}` or `${section.subsection}` and return a copy of the config with interpolated values. Can be used if a config is loaded with `interpolate=False`, e.g. via `Config.from_str`. ```python from confection import Config config_str = """ [hyper_params] dropout = 0.2 [training] dropout = ${hyper_params.dropout} """ config = Config().from_str(config_str, interpolate=False) print(config["training"]) # {'dropout': '${hyper_params.dropout}'}} config = config.interpolate() print(config["training"]) # {'dropout': 0.2}} ``` | Argument | Type | Description | | ----------- | -------- | ---------------------------------------------- | | **RETURNS** | `Config` | A copy of the config with interpolated values. | ##### method `Config.merge` Deep-merge two config objects, using the current config as the default. Only merges sections and dictionaries and not other values like lists. Values that are provided in the updates are overwritten in the base config, and any new values or sections are added. If a config value is a variable like `${section.key}` (e.g. if the config was loaded with `interpolate=False)`, **the variable is preferred**, even if the updates provide a different value. This ensures that variable references aren’t destroyed by a merge. > :warning: Note that blocks that refer to registered functions using the `@` > syntax are only merged if they are referring to the same functions. Otherwise, > merging could easily produce invalid configs, since different functions can > take different arguments. If a block refers to a different function, it’s > overwritten. ```python from confection import Config base_config_str = """ [training] patience = 10 dropout = 0.2 """ update_config_str = """ [training] dropout = 0.1 max_epochs = 2000 """ base_config = Config().from_str(base_config_str) update_config = Config().from_str(update_config_str) merged = Config(base_config).merge(update_config) print(merged["training"]) # {'patience': 10, 'dropout': 0.1, 'max_epochs': 2000} ``` | Argument | Type | Description | | ----------- | ------------------------------- | --------------------------------------------------- | | `overrides` | `Union[Dict[str, Any], Config]` | The updates to merge into the config. | | **RETURNS** | `Config` | A new config instance containing the merged config. | ### Config Attributes | Argument | Type | Description | | ----------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `is_interpolated` | `bool` | Whether the config values have been interpolated. Defaults to `True` and is set to `False` if a config is loaded with `interpolate=False`, e.g. using `Config.from_str`. | explosion-confection-a98f9d6/bin/000077500000000000000000000000001516055431200170655ustar00rootroot00000000000000explosion-confection-a98f9d6/bin/push-tags.sh000066400000000000000000000005371516055431200213410ustar00rootroot00000000000000#!/usr/bin/env bash set -e # Insist repository is clean git diff-index --quiet HEAD git checkout $1 git pull origin $1 git push origin $1 version=$(grep "version = " setup.cfg) version=${version/version = } version=${version/\'/} version=${version/\'/} version=${version/\"/} version=${version/\"/} git tag "v$version" git push origin "v$version"explosion-confection-a98f9d6/bin/release.sh000077500000000000000000000006631516055431200210510ustar00rootroot00000000000000#!/usr/bin/env bash set -e # Insist repository is clean git diff-index --quiet HEAD version=$(grep "version = " setup.cfg) version=${version/version = } version=${version/\'/} version=${version/\'/} version=${version/\"/} version=${version/\"/} echo "Pushing release-v"$version git tag -d release-v$version || true sudo gitw push origin :release-v$version || true git tag release-v$version sudo gitw push origin release-v$version explosion-confection-a98f9d6/confection/000077500000000000000000000000001516055431200204445ustar00rootroot00000000000000explosion-confection-a98f9d6/confection/__init__.py000066400000000000000000000011771516055431200225630ustar00rootroot00000000000000# FIXME some symbols are not in __all__; can we remove them? from ._config import Config # noqa: F401 from ._constants import ( # noqa: F401 ARGS_FIELD, ARGS_FIELD_ALIAS, RESERVED_FIELDS, SECTION_PREFIX, VARIABLE_RE, ) from ._errors import ConfigValidationError from ._registry import Promise, registry from .util import ( # noqa: F401 SimpleFrozenDict, SimpleFrozenList, try_dump_json, try_load_json, ) from .validation import Schema # noqa: F401 __all__ = [ "Config", "registry", "ConfigValidationError", "Promise", "Schema", "SimpleFrozenDict", "SimpleFrozenList", ] explosion-confection-a98f9d6/confection/_config.py000066400000000000000000000234401516055431200224250ustar00rootroot00000000000000from __future__ import annotations import copy import sys from pathlib import Path from typing import Any, Dict, List, Optional, Union if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self from ._errors import ConfectionError, ConfigValidationError from ._parser import parse_config, serialize_config from .validation import ValidationError, ensure_schema class Config(dict): # TODO: Improve doc string """Dict subclass to save TOML-style configuration format from/to string, file or bytes. """ is_interpolated: bool def __init__( self, data: Optional[Union[Dict[str, Any], "Config"]] = None, *, is_interpolated: Optional[bool] = None, section_order: Optional[List[str]] = None, ) -> None: """Initialize a new Config object with optional data.""" dict.__init__(self) if data is None: data = {} if not isinstance(data, (dict, Config)): raise ConfectionError( f"Can't initialize Config with data. Expected dict or " f"Config but got: {type(data)}" ) # Whether the config has been interpolated. We can use this to check # whether we need to interpolate again when it's resolved. We assume # that a config is interpolated by default. if is_interpolated is not None: self.is_interpolated = is_interpolated elif isinstance(data, Config): self.is_interpolated = data.is_interpolated else: self.is_interpolated = True if section_order is not None: self.section_order = section_order elif isinstance(data, Config): self.section_order = data.section_order else: self.section_order = [] # Update with data self.update(data) def interpolate(self) -> Self: """Interpolate (resolve var references) a config. Returns a copy of the object. """ # This is currently the most effective way because we need our custom # to_str logic to run in order to re-serialize the values so we can # interpolate them again. ConfigParser.read_dict will just call str() # on all values, which isn't enough. return type(self)().from_str(self.to_str()) def copy(self) -> Self: """Deepcopy the config.""" config = copy.deepcopy(self) return type(self)( config, is_interpolated=self.is_interpolated, section_order=self.section_order, ) def merge( self, updates: Union[Dict[str, Any], "Config"], remove_extra: bool = False ) -> Self: """Deep merge the config with updates, using current as defaults.""" defaults = self.copy() updates = Config(updates).copy() merged = deep_merge_configs(updates, defaults, remove_extra=remove_extra) return type(self)( merged, is_interpolated=defaults.is_interpolated and updates.is_interpolated, section_order=defaults.section_order, ) def validate(self, schema) -> Self: """Validate the config against a schema. Raises ConfigValidationError if validation fails. """ schema = ensure_schema(schema) _validate_recursive(dict(self), schema, self) return self def fill_defaults(self, schema) -> Self: """Fill in missing values from schema defaults and remove extra fields if the schema forbids them. Modifies in place and returns self. """ schema = ensure_schema(schema) extra = schema.model_config.get("extra", "allow") # Fill defaults for name, field in schema.model_fields.items(): if name not in self and not field.is_required(): self[name] = field.default elif name in self and isinstance(self[name], dict): field_schema = field.annotation if isinstance(field_schema, type) and hasattr( field_schema, "model_fields" ): sub_schema = ensure_schema(field_schema) _fill_defaults_recursive(self[name], sub_schema) # Strip extras if extra == "forbid": known = set(schema.model_fields.keys()) for key in list(self.keys()): if key not in known: del self[key] return self def from_str( self, text: str, *, interpolate: bool = True, overrides: Dict[str, Any] = {}, schema=None, ) -> Self: """Load the config from a string.""" self.clear() self.update(parse_config(text, interpolate=interpolate, overrides=overrides)) if overrides and interpolate: # Re-interpolate now that overrides are applied. The recursive # from_str call will have no overrides, so this doesn't loop. self = self.interpolate() self.is_interpolated = interpolate if schema is not None: self.fill_defaults(schema) self.validate(schema) return self def to_str(self, *, interpolate: bool = True) -> str: """Write the config to a string.""" return serialize_config(self, interpolate=interpolate) def to_bytes(self, *, interpolate: bool = True) -> bytes: """Serialize the config to a byte string.""" return self.to_str(interpolate=interpolate).encode("utf8") def from_bytes( self, bytes_data: bytes, *, interpolate: bool = True, overrides: Dict[str, Any] = {}, ) -> Self: """Load the config from a byte string.""" return self.from_str( bytes_data.decode("utf8"), interpolate=interpolate, overrides=overrides ) def to_disk(self, path: Union[str, Path], *, interpolate: bool = True) -> None: """Serialize the config to a file.""" path = Path(path) if isinstance(path, str) else path with path.open("w", encoding="utf8") as file_: file_.write(self.to_str(interpolate=interpolate)) def from_disk( self, path: Union[str, Path], *, interpolate: bool = True, overrides: Dict[str, Any] = {}, ) -> Self: """Load config from a file.""" path = Path(path) if isinstance(path, str) else path with path.open("r", encoding="utf8") as file_: text = file_.read() return self.from_str(text, interpolate=interpolate, overrides=overrides) def _fill_defaults_recursive(data, schema): """Fill defaults and strip extras recursively for nested schemas.""" extra = schema.model_config.get("extra", "allow") for name, field in schema.model_fields.items(): if name not in data and not field.is_required(): data[name] = field.default elif name in data and isinstance(data[name], dict): field_schema = field.annotation if isinstance(field_schema, type) and hasattr(field_schema, "model_fields"): _fill_defaults_recursive(data[name], ensure_schema(field_schema)) if extra == "forbid": known = set(schema.model_fields.keys()) for key in list(data.keys()): if key not in known: del data[key] def _validate_recursive(data, schema, config, parent=""): """Validate data against a schema, recursing into nested schemas.""" try: schema.model_validate(data) except ValidationError as e: section = f" in [{parent}]" if parent else "" raise ConfigValidationError( config=config, errors=e.errors(), title=f"Config validation error{section}", ) from None # Recurse into fields that are themselves schemas for name, field in schema.model_fields.items(): annotation = field.annotation if ( isinstance(annotation, type) and hasattr(annotation, "model_validate") and name in data and isinstance(data[name], dict) ): child_parent = f"{parent}.{name}" if parent else name _validate_recursive(data[name], annotation, config, parent=child_parent) def deep_merge_configs( config: Union[Dict[str, Any], Config], defaults: Union[Dict[str, Any], Config], *, remove_extra: bool = False, ) -> Union[Dict[str, Any], Config]: """Deep merge two configs.""" if remove_extra: # Filter out values in the original config that are not in defaults keys = list(config.keys()) for key in keys: if key not in defaults: del config[key] for key, value in defaults.items(): if isinstance(value, dict): node = config.setdefault(key, {}) if not isinstance(node, dict): continue value_promises = [k for k in value if k.startswith("@")] value_promise = value_promises[0] if value_promises else None node_promises = [k for k in node if k.startswith("@")] if node else [] node_promise = node_promises[0] if node_promises else None # We only update the block from defaults if it refers to the same # registered function if ( value_promise and node_promise and ( value_promise in node and node[value_promise] != value[value_promise] ) ): continue if node_promise and ( node_promise not in value or node[node_promise] != value[node_promise] ): continue defaults = deep_merge_configs(node, value, remove_extra=remove_extra) elif key not in config: config[key] = value return config explosion-confection-a98f9d6/confection/_constants.py000066400000000000000000000015161516055431200231740ustar00rootroot00000000000000import re # Regex to detect whether a value contains a variable VARIABLE_RE = re.compile(r"\$\{[\w\.:]+\}") # Internal prefix used to mark section references for custom interpolation SECTION_PREFIX = "__SECTION__:" # Field used for positional arguments, e.g. [section.*.xyz]. The alias is # required for the schema (shouldn't clash with user-defined arg names) ARGS_FIELD = "*" ARGS_FIELD_ALIAS = "VARIABLE_POSITIONAL_ARGS" # Aliases for fields that would otherwise shadow pydantic attributes. Can be any # string, so we're using name + space so it looks the same in error messages etc. RESERVED_FIELDS = { "validate": "validate\u0020", "model_config": "model_config\u0020", "model_validate": "model_validate\u2020", "model_fields": "model_fields\u2020", } RESERVED_FIELDS_REVERSE = {v: k for k, v in RESERVED_FIELDS.items()} explosion-confection-a98f9d6/confection/_errors.py000066400000000000000000000106411516055431200224730ustar00rootroot00000000000000from typing import Optional class ConfectionError(ValueError): ... class ConfigValidationError(ConfectionError): def __init__( self, *, config=None, errors=None, title: Optional[str] = "Config validation error", desc: Optional[str] = None, parent: Optional[str] = None, show_config: bool = True, ) -> None: """Custom error for validating configs. config (Union[Config, Dict[str, Dict[str, Any]], str]): The config the validation error refers to. errors (Union[Sequence[Mapping[str, Any]], Iterable[Dict[str, Any]]]): A list of errors as dicts with keys "loc" (list of strings describing the path of the value), "msg" (validation message to show) and optional "type" (mostly internals). Same format as produced by pydantic's validation error (e.errors()). title (str): The error title. desc (str): Optional error description, displayed below the title. parent (str): Optional parent to use as prefix for all error locations. For example, parent "element" will result in "element -> a -> b". show_config (bool): Whether to print the whole config with the error. ATTRIBUTES: config (Union[Config, Dict[str, Dict[str, Any]], str]): The config. errors (Iterable[Dict[str, Any]]): The errors. error_types (Set[str]): All "type" values defined in the errors, if available. This is most relevant for the pydantic errors that define types like "type_error.integer". This attribute makes it easy to check if a config validation error includes errors of a certain type, e.g. to log additional information or custom help messages. title (str): The title. desc (str): The description. parent (str): The parent. show_config (bool): Whether to show the config. text (str): The formatted error text. """ self.config = config self.errors = errors self.title = title self.desc = desc self.parent = parent self.show_config = show_config self.error_types = set() if self.errors: for error in self.errors: err_type = error.get("type") if err_type: self.error_types.add(err_type) self.text = self._format() ValueError.__init__(self, self.text) @classmethod def from_error( cls, err: "ConfigValidationError", title: Optional[str] = None, desc: Optional[str] = None, parent: Optional[str] = None, show_config: Optional[bool] = None, ) -> "ConfigValidationError": """Create a new ConfigValidationError based on an existing error, e.g. to re-raise it with different settings. If no overrides are provided, the values from the original error are used. err (ConfigValidationError): The original error. title (str): Overwrite error title. desc (str): Overwrite error description. parent (str): Overwrite error parent. show_config (bool): Overwrite whether to show config. RETURNS (ConfigValidationError): The new error. """ return cls( config=err.config, errors=err.errors, title=title if title is not None else err.title, desc=desc if desc is not None else err.desc, parent=parent if parent is not None else err.parent, show_config=show_config if show_config is not None else err.show_config, ) def _format(self) -> str: """Format the error message.""" loc_divider = "->" data = [] if self.errors: for error in self.errors: err_loc = f" {loc_divider} ".join( [str(p) for p in error.get("loc", [])] ) if self.parent: err_loc = f"{self.parent} {loc_divider} {err_loc}" data.append((err_loc, error.get("msg"))) result = [] if self.title: result.append(self.title) if self.desc: result.append(self.desc) if data: result.append("\n".join([f"{entry[0]}\t{entry[1]}" for entry in data])) if self.config and self.show_config: result.append(f"{self.config}") return "\n\n" + "\n".join(result) explosion-confection-a98f9d6/confection/_parser.py000066400000000000000000000422631516055431200224600ustar00rootroot00000000000000import io import json import warnings from configparser import ( MAX_INTERPOLATION_DEPTH, ConfigParser, ExtendedInterpolation, InterpolationDepthError, InterpolationMissingOptionError, InterpolationSyntaxError, NoOptionError, NoSectionError, ParsingError, ) from typing import Any, Dict, List from ._constants import SECTION_PREFIX from ._errors import ConfigValidationError from .util import VARIABLE_RE, try_dump_json, try_load_json def parse_config( text: str, *, interpolate: bool = True, overrides: Dict[str, Any] = {}, ) -> dict[str, Any]: """Parse a config string into a nested dict. Handles the full pipeline: parse with ConfigParser, validate structure, apply overrides, interpret values, and resolve section references. Returns the nested dict and whether a second interpolation pass is needed (when overrides were applied with interpolation enabled). """ config_parser = _get_configparser(interpolate=interpolate and not overrides) try: config_parser.read_string(text) except ParsingError as e: desc = f"Make sure the sections and values are formatted correctly.\n\n{e}" raise ConfigValidationError(desc=desc) from None errors = _validate_configparser(config_parser) if errors: raise errors[0] errors = _validate_overrides(config_parser, overrides) if errors: raise errors[0] result: dict[str, Any] = {} section_parts = [section.split(".") for section in config_parser.sections()] # Phase 1: Build the skeleton of nested dicts from section names. for parts in section_parts: node = result for part in parts[:-1]: node = node.setdefault(part, {}) if part == "*" else node[part] node.setdefault(parts[-1], {}) # Phase 2: Fill in values, processing breadth-first by section depth. for section, values in sorted( config_parser.items(), key=lambda x: len(x[0].split(".")) ): if section == "DEFAULT": continue parts = section.split(".") node = result for part in parts: node = node[part] for key in values: node[key] = _interpret_value(config_parser.get(section, key)) # Phase 3: Apply overrides on the nested dict. _apply_overrides(result, overrides) # Phase 4: Replace section reference placeholders with actual dicts. _replace_section_refs(result, result) return result def _apply_overrides(result: dict[str, Any], overrides: Dict[str, Any]) -> None: """Apply dot-notation overrides to a nested dict. Override paths have already been validated by _validate_overrides. """ for key, value in overrides.items(): path = key.split(".") node = result for part in path[:-1]: node = node[part] node[path[-1]] = value def serialize_config( data: dict[str, Any], *, interpolate: bool = True, inline_paths: frozenset[str] = frozenset(), ) -> str: """Serialize a nested config dict to a config string. inline_paths: dotted paths whose values should be serialized as inline JSON rather than expanded into subsections. For example, if "a.b" is in inline_paths, data["a"]["b"] (even if it's a dict) will be serialized as ``b = {"key": "value"}`` under [a]. """ flattened = _get_configparser(interpolate=interpolate) queue: list[tuple[tuple, dict[str, Any]]] = [(tuple(), data)] for path, node in queue: section_name = ".".join(path) is_kwarg = path and path[-1] != "*" has_leaves = any(not hasattr(v, "items") for v in node.values()) if path and has_leaves and not flattened.has_section(section_name): # Create sections that have leaf values (including * sections). flattened.add_section(section_name) elif is_kwarg and not flattened.has_section(section_name): flattened.add_section(section_name) for key, value in node.items(): child_path = f"{section_name}.{key}" if section_name else key if hasattr(value, "items") and child_path not in inline_paths: queue.append((path + (key,), value)) else: flattened.set(section_name, key, try_dump_json(value, node)) string_io = io.StringIO() flattened.write(string_io) return string_io.getvalue().strip() def _get_configparser(interpolate: bool = True) -> ConfigParser: config = ConfigParser(interpolation=_CustomInterpolation() if interpolate else None) # Preserve case of keys: https://stackoverflow.com/a/1611877/6400719 config.optionxform = str # type: ignore return config def _validate_configparser(config_parser: ConfigParser) -> list[ConfigValidationError]: """Validate a configparser's structure before interpreting it into a Config. Checks that: - No values leak into the DEFAULT section (top-level values without a section) - All parent sections exist for dotted section names (e.g. "a.b" requires "a") - No key in a section conflicts with a child section name - No interpolation errors in values """ errors = [] default_section = config_parser.defaults() if default_section: # pragma: no cover -- configparser raises ParsingError first err_title = "Found config values without a top-level section" err_msg = "not part of a section" err = [{"loc": [k], "msg": err_msg} for k in default_section] errors.append(ConfigValidationError(errors=err, title=err_title)) section_names = set(config_parser.sections()) for section in config_parser.sections(): path = section.split(".") for i in range(1, len(path)): # "*" is an implicit list section — it doesn't need a parent # section header, and paths through it are always valid. if path[i - 1] == "*": continue parent = ".".join(path[:i]) # A parent is valid if it's a declared section OR if the path # goes through a "*" component (which is implicitly created). if parent not in section_names and "*" not in parent.split("."): err_title = ( "Error parsing config section. Perhaps a section name is wrong?" ) err = [{"loc": path, "msg": f"Section '{path[i - 1]}' is not defined"}] errors.append(ConfigValidationError(errors=err, title=err_title)) break keys = set(config_parser.options(section)) for other in section_names: if other.startswith(section + "."): child = other[len(section) + 1 :].split(".")[0] if child in keys: err = [{"loc": other.split("."), "msg": "found conflicting values"}] errors.append(ConfigValidationError(errors=err)) return errors def _validate_overrides( config_parser: ConfigParser, overrides: dict[str, Any] ) -> list[ConfigValidationError]: errors = [] err_title = "Error parsing config overrides" for key in overrides: err_msg = "not a section value that can be overridden" err = [{"loc": key.split("."), "msg": err_msg}] if "." not in key: errors.append(ConfigValidationError(errors=err, title=err_title)) continue section, _ = key.rsplit(".", 1) # Check for section and accept if option not in config[section] if section not in config_parser: errors.append(ConfigValidationError(errors=err, title=err_title)) # TODO: Are we supposed to chek for the *option*? return errors def _interpret_value(value: Any) -> Any: """Interpret a single config value.""" result = try_load_json(value) # If value is a string and it contains a variable, use original value # (not interpreted string, which could lead to double quotes: # ${x.y} -> "${x.y}" -> "'${x.y}'"). Make sure to check it's a string, # so we're not keeping lists as strings. # NOTE: This currently can't handle uninterpolated values like [${x.y}]! if isinstance(result, str) and VARIABLE_RE.search(value): result = value return result def _replace_section_refs( root: dict[str, Any], node: dict[str, Any], parent: str = "" ) -> None: """Replace section reference placeholders with actual dicts.""" for key, value in node.items(): key_parent = f"{parent}.{key}".strip(".") if isinstance(value, dict): _replace_section_refs(root, value, parent=key_parent) elif isinstance(value, list): node[key] = [_get_section_ref(root, v, parent=[parent, key]) for v in value] else: node[key] = _get_section_ref(root, value, parent=[parent, key]) def _get_section_ref( root: Dict[str, Any], value: Any, *, parent: List[str] = [] ) -> Any: """Resolve a single section reference placeholder, or return value as-is.""" if isinstance(value, str) and value.startswith( f'"{SECTION_PREFIX}' ): # pragma: no cover value = try_load_json(value) # pragma: no cover if ( isinstance(value, str) and value.startswith(SECTION_PREFIX) and value != SECTION_PREFIX ): parts = value.replace(SECTION_PREFIX, "", 1).split(".") result = root for item in parts: result = result[item] return result elif isinstance(value, str) and SECTION_PREFIX in value and value != SECTION_PREFIX: err_desc = ( "Can't reference whole sections or return values of function " "blocks inside a string or list\n\nYou can change your variable to " "reference a value instead. Keep in mind that it's not " "possible to interpolate the return value of a registered " "function, since variables are interpolated when the config " "is loaded, and registered functions are resolved afterwards." ) err = [{"loc": parent, "msg": "uses section variable in string or list"}] raise ConfigValidationError(errors=err, desc=err_desc) return value class _CustomInterpolation(ExtendedInterpolation): def before_read( # pyright: ignore[reportIncompatibleMethodOverride] self, parser: ConfigParser, section: str, option: str, value: str ) -> str: # Warn about single-quoted strings (common mistake) if value and value[0] == value[-1] == "'": warnings.warn( f"The value [{value}] seems to be single-quoted, but values " "use JSON formatting, which requires double quotes." ) return super().before_read(parser, section, option, value) def _coerce_for_string_context(self, v: str) -> str: """Coerce a raw config value for use in a compound string expression.""" # Don't coerce section references - they need to stay quoted for JSON if SECTION_PREFIX in v: return v try: parsed = json.loads(v) except json.JSONDecodeError: return v # Not valid JSON, already a plain string if isinstance(parsed, str): return parsed # Unwrap JSON string # Use json.dumps() for non-strings, escaping inner quotes so they don't # conflict with the outer JSON string quotes return json.dumps(parsed).replace('"', '\\"') def before_get( # pyright: ignore[reportIncompatibleMethodOverride] self, parser: ConfigParser, section: str, option: str, value: str, defaults: Dict[str, str], ) -> str: # Mostly copy-pasted from the built-in configparser implementation. # The interpolate() method resolves ${...} references and appends pieces # to L. For a bare reference like ${x}, L has one element. For compound # expressions like "hello ${x}", L has multiple pieces that we join. # Compound results stay as strings (coerced via _coerce_for_string_context), # while bare references keep their JSON type for _interpret_value to parse. L: List[str] = [] self.interpolate(parser, option, L, value, section, defaults, 1) if len(L) == 1: return L[0] return "".join(self._coerce_for_string_context(piece) for piece in L) def interpolate( self, parser: ConfigParser, option: str, accum: List[str], rest: str, section: str, map: Dict[str, str], depth: int, ) -> None: """Resolve variable references like ${foo.bar}""" # Mostly copy-pasted from the built-in configparser implementation. # We need to overwrite this method so we can add special handling for # block references :( All values produced here should be strings – # we need to wait until the whole config is interpreted anyways so # filling in incomplete values here is pointless. All we need is the # section reference so we can fetch it later. rawval = parser.get(section, option, raw=True, fallback=rest) if depth > MAX_INTERPOLATION_DEPTH: raise InterpolationDepthError(option, section, rawval) while rest: p = rest.find("$") if p < 0: accum.append(rest) return if p > 0: accum.append(rest[:p]) rest = rest[p:] # p is no longer used c = rest[1:2] if c == "$": accum.append("$") rest = rest[2:] elif c == "{": # We want to treat both ${a:b} and ${a.b} the same m = self._KEYCRE.match(rest) # type: ignore[attr-defined] if m is None: err = f"bad interpolation variable reference {rest}" raise InterpolationSyntaxError(option, section, err) orig_var = m.group(1) path = orig_var.replace(":", ".").rsplit(".", 1) rest = rest[m.end() :] sect = section opt = option try: if len(path) == 1: opt = parser.optionxform(path[0]) # Check if the variable references a section rather # than a key in the current section. If the key # exists in the current map but its raw value is the # same interpolation variable (self-reference), or if # the key doesn't exist in the map, treat it as a # section reference. is_section_ref = opt not in map if not is_section_ref: raw = map[opt] is_section_ref = raw.strip() == rawval.strip() if not is_section_ref: v = map[opt] else: # We have block reference, store it as a special key section_name = parser[parser.optionxform(path[0])].name v = self._get_section_name(section_name) elif len(path) == 2: sect = path[0] opt = parser.optionxform(path[1]) fallback = "__FALLBACK__" v = parser.get(sect, opt, raw=True, fallback=fallback) # If a variable doesn't exist, try again and treat the # reference as a section if v == fallback: v = self._get_section_name(parser[f"{sect}.{opt}"].name) else: # pragma: no cover # Dead code: rsplit(".", 1) produces at most 2 elements err = f"More than one ':' found: {rest}" raise InterpolationSyntaxError(option, section, err) except (KeyError, NoSectionError, NoOptionError): raise InterpolationMissingOptionError( option, section, rawval, orig_var ) from None if "$" in v: new_map = dict(parser.items(sect, raw=True)) self.interpolate(parser, opt, accum, v, sect, new_map, depth + 1) else: accum.append(v) else: err = "'$' must be followed by '$' or '{', found: %r" % (rest,) raise InterpolationSyntaxError(option, section, err) def _get_section_name(self, name: str) -> str: """Generate the name of a section. Note that we use a quoted string here so we can use section references within lists and load the list as JSON. Since section references can't be used within strings, we don't need the quoted vs. unquoted distinction like we do for variables. Examples (assuming section = {"foo": 1}): - value: ${section.foo} -> value: 1 - value: "hello ${section.foo}" -> value: "hello 1" - value: ${section} -> value: {"foo": 1} - value: "${section}" -> value: {"foo": 1} - value: "hello ${section}" -> invalid """ return f'"{SECTION_PREFIX}{name}"' __all__ = ["parse_config", "serialize_config"] explosion-confection-a98f9d6/confection/_registry.py000066400000000000000000000373741516055431200230430ustar00rootroot00000000000000import inspect from dataclasses import dataclass from typing import ( Any, Callable, Dict, Generic, List, Optional, Tuple, TypeVar, Union, ) from ._config import Config from ._constants import ( ARGS_FIELD, ARGS_FIELD_ALIAS, RESERVED_FIELDS_REVERSE, ) from ._errors import ConfigValidationError from .util import is_promise from .validation import Schema _PromisedType = TypeVar("_PromisedType") @dataclass class Promise(Generic[_PromisedType]): registry: str name: str var_args: List[Any] kwargs: Dict[str, Any] getter: Union[Callable[..., _PromisedType], Exception] @property def return_type(self) -> _PromisedType: if isinstance(self.getter, Exception): # pragma: no cover raise self.getter # pragma: no cover signature = inspect.signature(self.getter) return signature.return_annotation def resolve(self) -> Any: if isinstance(self.getter, Exception): # pragma: no cover raise self.getter # pragma: no cover kwargs = _recursive_resolve(self.kwargs) args = _recursive_resolve(self.var_args) args = list(args.values()) if isinstance(args, dict) else args kwargs = _coerce_basemodel_args(self.getter, kwargs) return self.getter(*args, **kwargs) # type: ignore @classmethod def from_dict(cls, registry, values) -> "Promise": reg_name, func_name = registry.get_constructor(values) var_args, kwargs = registry.parse_args(values) try: getter = registry.get(reg_name, func_name) except Exception as e: # pragma: no cover getter = e # pragma: no cover output = cls( registry=reg_name, name=func_name, var_args=var_args, kwargs=kwargs, getter=getter, ) return output def _coerce_basemodel_args(func, kwargs): """Coerce dict kwargs to BaseModel instances where the function signature expects a BaseModel subclass. This lets registered functions receive constructed model instances instead of raw dicts (issue #58). """ try: from typing import get_type_hints hints = get_type_hints(func) except Exception: return kwargs result = dict(kwargs) for name, value in result.items(): if not isinstance(value, dict): continue hint = hints.get(name) if hint is None or not isinstance(hint, type): continue # Check for pydantic BaseModel (v1 or v2) if hasattr(hint, "model_validate"): result[name] = hint.model_validate(value) elif hasattr(hint, "parse_obj"): result[name] = hint.parse_obj(value) return result def _recursive_resolve(obj): if isinstance(obj, list): return [_recursive_resolve(v) for v in obj] elif isinstance(obj, dict): return {k: _recursive_resolve(v) for k, v in obj.items()} elif isinstance(obj, Promise): return obj.resolve() else: return obj class registry: @classmethod def has(cls, registry_name: str, func_name: str) -> bool: """Check whether a function is available in a registry.""" if not hasattr(cls, registry_name): return False reg = getattr(cls, registry_name) return func_name in reg @classmethod def get(cls, registry_name: str, func_name: str) -> Callable: """Get a registered function from a given registry.""" if not hasattr(cls, registry_name): raise ValueError(f"Unknown registry: '{registry_name}'") reg = getattr(cls, registry_name) func = reg.get(func_name) if func is None: # pragma: no cover -- catalogue raises RegistryError first raise ValueError(f"Could not find '{func_name}' in '{registry_name}'") return func @classmethod def resolve( cls, config: Union[Config, Dict[str, Dict[str, Any]]], *, schema=None, overrides: Dict[str, Any] = {}, validate: bool = True, ) -> Dict[str, Any]: config = cls.fill( config, schema=schema, overrides=overrides, interpolate=True, ) promised = insert_promises(cls, config, resolve=True) resolved = resolve_promises(promised) fixed = fix_positionals(resolved) assert isinstance(fixed, dict) if schema is not None and validate: Config(fixed).validate(schema) return fixed @classmethod def fill( cls, config: Union[Config, Dict[str, Dict[str, Any]]], *, schema=None, overrides: Dict[str, Any] = {}, interpolate: bool = False, validate: bool = True, ) -> Config: if cls.is_promise(config): err_msg = "The top-level config object can't be a reference to a registered function." raise ConfigValidationError(config=config, errors=[{"msg": err_msg}]) # If a Config was loaded with interpolate=False, we assume it needs to # be interpolated first, otherwise we take it at face value is_interpolated = not isinstance(config, Config) or config.is_interpolated section_order = config.section_order if isinstance(config, Config) else None orig_config = config if not is_interpolated: config = Config(orig_config).interpolate() filled = fill_config(cls, config, overrides=overrides, validate=validate) filled = Config(filled, section_order=section_order) # Merge the original config back to preserve variables if we started # with a config that wasn't interpolated. Here, we prefer variables to # allow auto-filling a non-interpolated config without destroying # variable references. if not interpolate and not is_interpolated: filled = filled.merge( Config(orig_config, is_interpolated=False), remove_extra=True ) if schema is not None: filled.fill_defaults(schema) return filled @classmethod def is_promise(cls, obj: Any) -> bool: """Check whether an object is a "promise", i.e. contains a reference to a registered function (via a key starting with `"@"`. """ return is_promise(obj) @classmethod def get_constructor(cls, obj: Dict[str, Any]) -> Tuple[str, str]: id_keys = [k for k in obj.keys() if k.startswith("@")] if len(id_keys) != 1: err_msg = f"A block can only contain one function registry reference. Got: {id_keys}" raise ConfigValidationError(config=obj, errors=[{"msg": err_msg}]) else: key = id_keys[0] value = obj[key] return (key[1:], value) @classmethod def parse_args(cls, obj: Dict[str, Any]) -> Tuple[List[Any], Dict[str, Any]]: args = [] kwargs = {} for key, value in obj.items(): if not key.startswith("@"): if key == ARGS_FIELD: args = value else: kwargs[key] = value return args, kwargs def _is_config_section(obj) -> bool: """Check if a dict is a config section (all string keys) vs a data value.""" if not isinstance(obj, dict): return False return all(isinstance(k, str) for k in obj.keys()) def alias_generator(name: str) -> str: """Generate field aliases in promise schema.""" # Underscore fields are not allowed in model, so use alias if name == ARGS_FIELD_ALIAS: return ARGS_FIELD # Auto-alias fields that shadow base model attributes return RESERVED_FIELDS_REVERSE.get(name, name) def fill_config( registry, config: Dict[str, Any], *, overrides: Dict[str, Dict[str, Any]] = {}, validate: bool = True, ) -> Dict[str, Any]: overrided = apply_overrides(dict(config), overrides) return _fill_defaults(registry, overrided, validate=validate) def _fill_defaults( registry, config: Dict[str, Any], *, validate: bool = True ) -> Dict[str, Any]: """Recursively fill default values from registered function signatures.""" output = dict(config) for key, value in output.items(): if is_promise(value): output[key] = _fill_promise_defaults( registry, value, validate=validate, parent=key ) elif isinstance(value, dict): output[key] = _fill_defaults(registry, value, validate=validate) return output def _fill_promise_defaults( registry, promise_dict: Dict[str, Any], *, validate: bool = True, parent: str = "", ) -> Dict[str, Any]: """Fill default argument values for a promise block from the function signature.""" reg_name, func_name = registry.get_constructor(promise_dict) if not registry.has(reg_name, func_name): # Can't fill defaults for unknown functions — they'll error at resolve time return dict(promise_dict) func = registry.get(reg_name, func_name) schema = Schema.from_function(func) filled = dict(promise_dict) # Fill in defaults from the schema for param_name, field in schema.model_fields.items(): if param_name not in filled and not field.is_required(): filled[param_name] = field.default # Validate: check for missing required args and type errors if validate: _validate_promise_args(filled, schema, func_name, parent) # Recurse into nested values (which may themselves be promises) for key, value in filled.items(): if key.startswith("@"): continue if is_promise(value): filled[key] = _fill_promise_defaults( registry, value, validate=validate, parent=f"{parent}.{key}" ) elif isinstance(value, dict): filled[key] = _fill_defaults(registry, value, validate=validate) return filled def _validate_promise_args( filled: Dict[str, Any], schema, func_name: str, parent: str, ) -> None: """Validate promise arguments against the function schema.""" from .validation import validate_type errors = [] for param_name, field in schema.model_fields.items(): # Positional args (*args) are stored under the "*" key in the config # as a dict of named entries — skip type validation since the dict # form doesn't match Sequence[T] yet (fix_positionals converts later) effective_name = param_name if param_name not in filled and ARGS_FIELD in filled: effective_name = ARGS_FIELD if effective_name not in filled: if field.is_required(): errors.append( { "loc": [parent, param_name] if parent else [param_name], "msg": f"missing required argument: '{param_name}'", } ) elif effective_name == ARGS_FIELD: pass # Skip validation — dict of positional args, not final form elif not is_promise(filled[effective_name]): # Only validate non-promise values — promises will be validated # when they're resolved err = validate_type(filled[effective_name], field.annotation) if err: errors.append( { "loc": [parent, param_name] if parent else [param_name], "msg": err, } ) # Check for unexpected arguments (@ keys are registry refs, * is positional args) known = ( set(schema.model_fields.keys()) | {k for k in filled if k.startswith("@")} | {ARGS_FIELD} ) for key in filled: if key not in known: errors.append( { "loc": [parent, key] if parent else [key], "msg": f"unexpected argument: '{key}'", } ) if errors: raise ConfigValidationError( config=filled, errors=errors, title=f"Config error for '{func_name}'", ) def insert_promises( registry, config: Dict[str, Dict[str, Any]], resolve: bool ) -> Dict[str, Dict[str, Any]]: """Create a version of a config dict where promises are recognised and replaced by Promise dataclasses """ output = {} for key, value in config.items(): if registry.is_promise(value): value = insert_promises(registry, value, resolve=resolve) output[key] = Promise.from_dict( registry, value, ) elif isinstance(value, dict): output[key] = insert_promises(registry, value, resolve=resolve) else: output[key] = value return output def resolve_promises(config: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: output = {} for key, value in config.items(): if isinstance(value, dict): output[key] = resolve_promises(value) elif isinstance(value, Promise): output[key] = value.resolve() else: output[key] = value return output def fix_positionals(config): """Ensure positionals are provided as a tuple, rather than a dict.""" if isinstance(config, dict): output = {} for key, value in config.items(): if key == ARGS_FIELD and isinstance(value, dict): value = tuple(value.values()) if isinstance(value, dict): value = fix_positionals(value) elif isinstance(value, list) or isinstance(value, tuple): value = fix_positionals(value) output[key] = value return output elif isinstance(config, list): return [fix_positionals(v) for v in config] elif isinstance(config, tuple): return tuple([fix_positionals(v) for v in config]) else: return config def _deep_copy_with_uncopyable(obj: Any, memo: Optional[Dict[int, Any]] = None) -> Any: """Deep copy that passes through objects that can't be copied (like generators).""" if memo is None: memo = {} obj_id = id(obj) if obj_id in memo: return memo[obj_id] if isinstance(obj, dict): result = {} memo[obj_id] = result for k, v in obj.items(): result[_deep_copy_with_uncopyable(k, memo)] = _deep_copy_with_uncopyable( v, memo ) return result elif isinstance(obj, list): result = [] memo[obj_id] = result for item in obj: result.append(_deep_copy_with_uncopyable(item, memo)) return result elif isinstance(obj, tuple): # Tuples are immutable, but we still need to copy their contents return tuple(_deep_copy_with_uncopyable(item, memo) for item in obj) else: return obj def apply_overrides( config: Dict[str, Dict[str, Any]], overrides: Dict[str, Dict[str, Any]], ) -> Dict[str, Dict[str, Any]]: """Build first representation of the config:""" output = dict(config) for key, value in overrides.items(): path = key.split(".") err_title = "Error parsing config overrides" err_msg = "not a section value that can be overridden" err = [{"loc": path, "msg": err_msg}] node = output for subkey in path[:-1]: if ( not isinstance(node, dict) or subkey not in node ): # pragma: no cover -- overrides validated in _parser raise ConfigValidationError( errors=err, title=err_title ) # pragma: no cover node = node[subkey] if path[-1] not in node: # pragma: no cover raise ConfigValidationError(errors=err, title=err_title) # pragma: no cover node[path[-1]] = value # pragma: no cover return output explosion-confection-a98f9d6/confection/py.typed000066400000000000000000000000001516055431200221310ustar00rootroot00000000000000explosion-confection-a98f9d6/confection/typechecker.py000066400000000000000000000470431516055431200233340ustar00rootroot00000000000000""" A structural type checker with clean separation of concerns. The checker validates values against type annotations without requiring the types to be instantiated. It supports standard library types, generics, Union/Optional, Literal, Annotated, TypeVar, NewType, dataclasses, and pydantic-compatible custom types. Architecture: check_type - entry point, dispatches to custom handlers or standard path get_annot_branches - peels Union/Optional into flat alternatives check_branch - outer_match then decompose+recurse outer_match - does the value match this annotation at the top level? decompose - yield (child_value, child_annotation, child_ctx) triples """ from __future__ import annotations import collections.abc import inspect import types from dataclasses import dataclass, field, is_dataclass from dataclasses import fields as dataclass_fields from enum import Enum from pathlib import PurePath from types import GeneratorType from typing import ( Annotated, Any, Callable, Dict, ForwardRef, Iterator, Literal, Optional, Tuple, TypeVar, Union, get_args, get_origin, ) # --------------------------------------------------------------------------- # Error accumulation # --------------------------------------------------------------------------- @dataclass class TypeCheckError: """A single type-check failure, recording the path, value, and expected type.""" path: Tuple[Any, ...] value: Any annotation: Any def __str__(self) -> str: path_str = " → ".join(str(p) for p in self.path) if self.path else "root" return f"at {path_str}: {self.value!r} is not {self.annotation}" @dataclass class Ctx: """Accumulates errors during a type-check traversal. All recursive calls share the same ``errors`` list via ``child()``, so errors from any depth are collected in one place. """ path: Tuple[Any, ...] = () errors: list[TypeCheckError] = field(default_factory=list) def child(self, segment: Any) -> Ctx: """Create a child context with *segment* appended to the path.""" return Ctx(self.path + (segment,), self.errors) # shared errors list def fail(self, value: Any, annotation: Any) -> None: """Record a type-check failure at the current path.""" self.errors.append(TypeCheckError(self.path, value, annotation)) # --------------------------------------------------------------------------- # Main entry points # --------------------------------------------------------------------------- def check_type( value: Any, annotation: Any, custom_handlers: Optional[Dict[type, Callable[..., bool]]] = None, ctx: Optional[Ctx] = None, ) -> bool: """Check whether *value* is compatible with *annotation*. Returns ``True`` if the value matches, ``False`` otherwise. Errors are accumulated in *ctx* (created automatically if not provided). *custom_handlers* maps ``type(value)`` to a callable ``(value, annotation, handlers, ctx) -> bool`` that overrides the default checking logic for that runtime type. """ if custom_handlers is None: custom_handlers = {} if ctx is None: ctx = Ctx() if type(value) in custom_handlers: return custom_handlers[type(value)](value, annotation, custom_handlers, ctx) return any( check_branch(value, branch, custom_handlers, ctx) for branch in get_annot_branches(annotation) ) def check_branch( value: Any, annotation: Any, custom_handlers: Dict[type, Callable[..., bool]], ctx: Ctx, ) -> bool: """Check *value* against a single (non-Union) annotation branch. First checks the top-level match via ``outer_match``, then recursively checks children yielded by ``decompose``. """ if not outer_match(value, annotation): ctx.fail(value, annotation) return False return all( check_type(v, a, custom_handlers, child_ctx) for v, a, child_ctx in decompose(value, annotation, ctx) ) # --------------------------------------------------------------------------- # get_annot_branches: peel Union/Optional into flat alternatives # --------------------------------------------------------------------------- def get_annot_branches(annotation: Any) -> Tuple[Any, ...]: """Split a (possibly Union) annotation into individual branches. ``Union[int, str]`` becomes ``(int, str)``. ``Optional[X]`` becomes ``(X, NoneType)``. ``X | Y`` (Python 3.10+) is handled via ``types.UnionType``. Non-union annotations are returned as a single-element tuple. """ origin = get_origin(annotation) # Union[X, Y] and Optional[X] (which is Union[X, None]) if origin is Union: return get_args(annotation) # types.UnionType handles X | Y syntax (Python 3.10+) if origin is types.UnionType or isinstance(annotation, types.UnionType): return get_args(annotation) return (annotation,) # --------------------------------------------------------------------------- # outer_match: does the value match at this level, ignoring children? # --------------------------------------------------------------------------- #: Map from typing generic origins to the runtime types used for isinstance. ORIGIN_TO_BUILTIN: Dict[Any, Any] = { list: list, dict: dict, tuple: tuple, set: set, frozenset: frozenset, collections.abc.Sequence: collections.abc.Sequence, collections.abc.MutableSequence: collections.abc.MutableSequence, collections.abc.Set: collections.abc.Set, collections.abc.MutableSet: collections.abc.MutableSet, collections.abc.Mapping: collections.abc.Mapping, collections.abc.MutableMapping: collections.abc.MutableMapping, collections.abc.Callable: collections.abc.Callable, collections.abc.Iterable: collections.abc.Iterable, collections.abc.Iterator: collections.abc.Iterator, } def outer_match(value: Any, annotation: Any) -> bool: """Check whether *value* matches *annotation* at the top level. This does **not** recurse into container elements — that is the job of ``decompose`` + ``check_branch``. Coercion rules: * ``bool`` requires an exact bool (``0``/``1`` are rejected). * ``int`` accepts ints and parseable strings, but rejects bools. * ``float`` accepts ints, floats, and parseable strings, but rejects bools. * ``Path``/``PurePath`` accept strings. * ``str`` enums accept valid member value strings. * Generators / iterators always pass (to avoid consuming them). * Unresolved ``ForwardRef`` and string annotations always pass. """ # Any / Parameter.empty matches everything if annotation is Any or annotation is inspect.Parameter.empty: return True # None/NoneType if annotation is None or annotation is type(None): return value is None # Generators pass through without consumption if isinstance(value, (GeneratorType, collections.abc.Iterator)) and not isinstance( value, (str, bytes) ): return True # Literal[v1, v2, ...] if get_origin(annotation) is Literal: return value in get_args(annotation) # Annotated[T, ...] — unwrap to inner type if get_origin(annotation) is Annotated: inner = get_args(annotation)[0] metadata = get_args(annotation)[1:] if _has_strict_metadata(metadata): return _strict_match(value, inner) return outer_match(value, inner) # Type[X] — value should be a class that is a subclass of X if get_origin(annotation) is type: args = get_args(annotation) if not isinstance(value, type): return False if args and args[0] is not Any: try: return issubclass(value, args[0]) except ( TypeError ): # pragma: no cover -- modern Python handles Union in issubclass return True # pragma: no cover return True # Callable — just check callability here, signature checking is hard # and arguably belongs in a custom handler if get_origin(annotation) is collections.abc.Callable: return callable(value) # Generic types: List[int], Dict[str, int], etc. origin = get_origin(annotation) if origin is not None: check_against = ORIGIN_TO_BUILTIN.get(origin, origin) try: if not isinstance(value, check_against): return False except TypeError: # pragma: no cover -- custom generics with non-type origins return True # pragma: no cover # Fixed-length tuple: check length here if origin is tuple: args = get_args(annotation) if args and not (len(args) == 2 and args[1] is Ellipsis): if len(value) != len(args): return False return True # NewType — unwrap to supertype if callable(annotation) and hasattr(annotation, "__supertype__"): return outer_match(value, annotation.__supertype__) # pyright: ignore[reportFunctionMemberAccess] # TypeVar if isinstance(annotation, TypeVar): bound = annotation.__bound__ constraints = annotation.__constraints__ if bound: try: return isinstance(value, bound) except TypeError: # bound contains unresolved ForwardRefs or complex generics; # use check_type which handles Union via get_annot_branches return check_type(value, bound) if constraints: return any(check_type(value, c) for c in constraints) return True # Forward references — can't resolve, accept if isinstance(annotation, (str, ForwardRef)): return True # --- Plain types with coercion --- if isinstance(annotation, type): # bool: exact type (don't accept int 0/1) if annotation is bool: return isinstance(value, bool) # int: accept ints (not bools), reject strings that don't parse if annotation is int: if isinstance(value, bool): return False if isinstance(value, int): return True if isinstance(value, str): try: int(value) return True except (ValueError, TypeError): return False return False # float: accept int/float (not bools), strings that parse if annotation is float: if isinstance(value, bool): return False if isinstance(value, (int, float)): return True if isinstance(value, str): try: float(value) return True except (ValueError, TypeError): return False return False # str: straightforward if annotation is str: return isinstance(value, str) # Path: accept strings if issubclass(annotation, PurePath): return isinstance(value, (str, PurePath)) # str enums: accept plain strings that are valid member values if issubclass(annotation, str) and issubclass(annotation, Enum): try: annotation(value) return True except (ValueError, KeyError): return False # Dataclass / Schema with dict value: accept for decompose if is_dataclass(annotation) and isinstance(value, dict): return True if hasattr(annotation, "model_fields") and isinstance(value, dict): return True # Pydantic v2 validator hook if hasattr(annotation, "__get_pydantic_core_schema__"): return _pydantic_v2_match(value, annotation) # Pydantic v1 validator hook if hasattr(annotation, "__get_validators__"): return _pydantic_v1_match(value, annotation) # Default isinstance return isinstance(value, annotation) return False # --------------------------------------------------------------------------- # Helpers for outer_match # --------------------------------------------------------------------------- def _resolve_dataclass_hints(cls: type) -> Dict[str, Any]: """Resolve forward references in a dataclass's type annotations. Uses ``get_type_hints`` with the class's module globals so that forward references like ``ForwardRef('Floats3d')`` are resolved to actual types. Returns an empty dict on failure. """ import sys from typing import get_type_hints mod = sys.modules.get(cls.__module__) globalns = vars(mod) if mod else None try: return get_type_hints(cls, globalns=globalns) except (NameError, AttributeError, TypeError, RecursionError): # pragma: no cover return {} # pragma: no cover def _has_strict_metadata(metadata: Tuple[Any, ...]) -> bool: """Check if ``Annotated`` metadata contains a ``Strict()`` marker.""" return any(getattr(m, "strict", False) for m in metadata if hasattr(m, "strict")) def _strict_match(value: Any, inner_type: type) -> bool: """Exact type match for ``Annotated[X, Strict()]``. Unlike the normal coercion rules, strict matching requires ``type(value)`` to be exactly the annotated type. """ if inner_type is int: return type(value) is int and not isinstance(value, bool) if inner_type is float: return type(value) is float if inner_type is str: return type(value) is str if inner_type is bool: return type(value) is bool return isinstance(value, inner_type) class _AnySchemaHandler: """Minimal stand-in for pydantic's ``GetCoreSchemaHandler``.""" def __call__( self, _source_type: Any ) -> Dict[str, Any]: # pragma: no cover -- called internally by pydantic hooks return {"type": "any"} # pragma: no cover def _pydantic_v2_match(value: Any, annotation: type) -> bool: """Check *value* against a type with ``__get_pydantic_core_schema__``. Extracts the validator function from the pydantic core schema and calls it. Falls back to ``isinstance`` if the value is already an instance. """ if isinstance(value, annotation): return True try: schema = annotation.__get_pydantic_core_schema__( annotation, _AnySchemaHandler() ) fn_entry = schema.get("function", {}) validator = fn_entry.get("function") if isinstance(fn_entry, dict) else None if callable(validator): validator(value) return True except (ValueError, TypeError, AssertionError): return False return False class _PydanticV1FieldShim: """Minimal shim providing ``field.type_`` for pydantic v1 validators.""" def __init__(self, typ: type) -> None: self.type_ = typ def _pydantic_v1_match(value: Any, annotation: type) -> bool: """Check *value* against a type with ``__get_validators__``. Iterates through the validators yielded by the type's ``__get_validators__`` classmethod. Validators with more than 2 parameters are skipped (they require a ``config`` argument we don't have). """ if isinstance(value, annotation): return True shim = _PydanticV1FieldShim(annotation) for validator in annotation.__get_validators__(): try: nparams = len(inspect.signature(validator).parameters) except (ValueError, TypeError): nparams = 1 if nparams > 2: continue try: value = validator(value) if nparams == 1 else validator(value, shim) except (ValueError, TypeError, AssertionError): return False return True # --------------------------------------------------------------------------- # decompose: yield (child_value, child_annotation, child_ctx) triples # --------------------------------------------------------------------------- #: Origins that are sequence-like: one type arg, fan across elements. SEQUENCE_ORIGINS: set[Any] = { list, set, frozenset, collections.abc.Sequence, collections.abc.MutableSequence, collections.abc.Set, collections.abc.MutableSet, collections.abc.Iterable, collections.abc.Iterator, } #: Origins that are mapping-like: two type args (key, value). MAPPING_ORIGINS: set[Any] = { dict, collections.abc.Mapping, collections.abc.MutableMapping, } def decompose(value: Any, annotation: Any, ctx: Ctx) -> Iterator[Tuple[Any, Any, Ctx]]: """Yield ``(child_value, child_annotation, child_ctx)`` triples. This is the recursive engine of the type checker. For container types it fans out over elements; for dataclasses and schemas it fans out over fields. ``outer_match`` has already confirmed the top-level match, so we only need to check the children here. """ # Annotated[T, ...] — unwrap if get_origin(annotation) is Annotated: annotation = get_args(annotation)[0] origin = get_origin(annotation) args = get_args(annotation) # Schema / model_fields annotation with dict value — fan out over fields if ( isinstance(annotation, type) and hasattr(annotation, "model_fields") and isinstance(value, dict) ): for name, field_info in annotation.model_fields.items(): data_key = name if hasattr(field_info, "alias") and field_info.alias is not None: data_key = field_info.alias if data_key in value: yield (value[data_key], field_info.annotation, ctx.child(data_key)) return # Dataclass annotation with dict value — fan out over fields if ( isinstance(annotation, type) and is_dataclass(annotation) and isinstance(value, dict) ): resolved_hints = _resolve_dataclass_hints(annotation) for f in dataclass_fields(annotation): if f.name in value: hint = resolved_hints.get(f.name, f.type) yield (value[f.name], hint, ctx.child(f.name)) return # Dataclass annotation with dataclass value — match fields if ( isinstance(annotation, type) and is_dataclass(annotation) and is_dataclass(value) ): resolved_hints = _resolve_dataclass_hints(annotation) for f in dataclass_fields(annotation): if hasattr(value, f.name): hint = resolved_hints.get(f.name, f.type) yield (getattr(value, f.name), hint, ctx.child(f.name)) return # No type args means nothing to recurse into for generics if not args: return # Tuple: fixed-length or variable-length if origin is tuple: if len(args) == 2 and args[1] is Ellipsis: # Tuple[int, ...] — variable length, one type for i, elem in enumerate(value): yield (elem, args[0], ctx.child(i)) else: # Tuple[int, str, float] — fixed length # Length already verified in outer_match for i, (elem, arg) in enumerate(zip(value, args)): yield (elem, arg, ctx.child(i)) return # Sequence-like if origin in SEQUENCE_ORIGINS: arg = args[0] for i, elem in enumerate(value): yield (elem, arg, ctx.child(i)) return # Mapping-like if origin in MAPPING_ORIGINS: k_ann, v_ann = args[0], args[1] for k in value: yield (k, k_ann, ctx.child(f"key({k!r})")) yield (value[k], v_ann, ctx.child(k)) return # Type[X], Callable, TypeVar, Literal — no children to decompose # (fully resolved in outer_match) return explosion-confection-a98f9d6/confection/util.py000066400000000000000000000123771516055431200220050ustar00rootroot00000000000000import functools import json import re from copy import deepcopy from typing import Any, Callable, Protocol, TypeVar from ._constants import VARIABLE_RE from ._errors import ConfigValidationError _DIn = TypeVar("_DIn") class Decorator(Protocol): """Protocol to mark a function as returning its child with identical signature.""" def __call__(self, name: str) -> Callable[[_DIn], _DIn]: ... # This is how functools.partials seems to do it, too, to retain the return type PartialT = TypeVar("PartialT") def partial( func: Callable[..., PartialT], *args: Any, **kwargs: Any ) -> Callable[..., PartialT]: """Wrapper around functools.partial that retains docstrings and can include other workarounds if needed. """ partial_func = functools.partial(func, *args, **kwargs) partial_func.__doc__ = func.__doc__ return partial_func DEFAULT_FROZEN_DICT_ERROR = ( "Can't write to frozen dictionary. This is likely an internal " "error. Are you writing to a default function argument?" ) DEFAULT_FROZEN_LIST_ERROR = ( "Can't write to frozen list. Maybe you're trying to modify a computed " "property or default function argument?" ) class SimpleFrozenDict(dict): """Simplified implementation of a frozen dict, mainly used as default function or method argument (for arguments that should default to empty dictionary). Will raise an error if the user attempts to add to dict. """ def __init__( self, *args, error: str = DEFAULT_FROZEN_DICT_ERROR, **kwargs, ) -> None: """Initialize the frozen dict. Can be initialized with pre-defined values. error (str): The error message when user tries to assign to dict. """ super().__init__(*args, **kwargs) self.error = error def __setitem__(self, key, value): raise NotImplementedError(self.error) def pop(self, key, default=None): raise NotImplementedError(self.error) def update(self, other=(), /, **kwargs): # pyright: ignore[reportIncompatibleMethodOverride] raise NotImplementedError(self.error) def __deepcopy__(self, memo): return self.__class__(deepcopy({k: v for k, v in self.items()})) class SimpleFrozenList(list): """Wrapper class around a list that lets us raise custom errors if certain attributes/methods are accessed. Mostly used for properties that return an immutable list (and that we don't want to convert to a tuple to not break too much backwards compatibility). If a user accidentally calls frozen_list.append(), we can raise a more helpful error. """ def __init__( self, *args, error: str = DEFAULT_FROZEN_LIST_ERROR, ) -> None: """Initialize the frozen list. error (str): The error message when user tries to mutate the list. """ self.error = error super().__init__(*args) def append(self, *args, **kwargs): raise NotImplementedError(self.error) def clear(self, *args, **kwargs): raise NotImplementedError(self.error) def extend(self, *args, **kwargs): raise NotImplementedError(self.error) def insert(self, *args, **kwargs): raise NotImplementedError(self.error) def pop(self, *args, **kwargs): raise NotImplementedError(self.error) def remove(self, *args, **kwargs): raise NotImplementedError(self.error) def reverse(self, *args, **kwargs): raise NotImplementedError(self.error) def sort(self, *args, **kwargs): raise NotImplementedError(self.error) def __deepcopy__(self, memo): return self.__class__(deepcopy(v) for v in self) def is_promise(obj) -> bool: if not hasattr(obj, "keys"): return False id_keys = [k for k in obj.keys() if isinstance(k, str) and k.startswith("@")] if len(id_keys): return True return False _PYTHON_LITERALS = {"True": True, "False": False, "None": None} def try_load_json(value: str) -> Any: """Load a JSON string if possible, otherwise default to original value. Also handles Python-style literals ``True``, ``False``, and ``None`` which are not valid JSON but are commonly used in config files. """ try: return json.loads(value) except Exception: if value in _PYTHON_LITERALS: return _PYTHON_LITERALS[value] return value def try_dump_json(value: Any, data: dict[str, dict] | str = "") -> str: """Dump a config value as JSON and output user-friendly error if it fails.""" # Special case if we have a variable: it's already a string so don't dump # to preserve ${x:y} vs. "${x:y}" if isinstance(value, str) and VARIABLE_RE.search(value): return value try: value = json.dumps(value) except Exception as e: err_msg = ( f"Couldn't serialize config value of type {type(value)}: {e}. Make " f"sure all values in your config are JSON-serializable. If you want " f"to include Python objects, use a registered function that returns " f"the object instead." ) raise ConfigValidationError(config=data, desc=err_msg) from e # Escape $ to $$ for configparser, but preserve ${...} variable references return re.sub(r"\$(?!\{)", "$$", value) explosion-confection-a98f9d6/confection/validation.py000066400000000000000000000467041516055431200231630ustar00rootroot00000000000000"""Lightweight type validation system replacing Pydantic. Provides Schema base class, dynamic schema creation, and type validation for config values against function signatures. """ import inspect import sys from typing import Any, Optional, get_type_hints from .typechecker import Ctx from .typechecker import check_type as _tc2_check_type # Optional pydantic imports — confection doesn't depend on pydantic, # but if it's installed we can detect and convert BaseModel schemas. # Skip pydantic.v1 on Python 3.14+ where it is unsupported. if sys.version_info >= (3, 14): _PydanticV1BaseModel = None # type: ignore[assignment,misc] _PydanticV1ValidationError = None # type: ignore[assignment,misc] else: try: from pydantic.v1 import ( BaseModel as _PydanticV1BaseModel, # pyright: ignore[reportMissingImports] ) from pydantic.v1 import ( ValidationError as _PydanticV1ValidationError, # pyright: ignore[reportMissingImports] ) except (ImportError, ModuleNotFoundError): # pragma: no cover _PydanticV1BaseModel = None # type: ignore[assignment,misc] _PydanticV1ValidationError = None # type: ignore[assignment,misc] try: from pydantic import ( BaseModel as _PydanticV2BaseModel, # pyright: ignore[reportMissingImports] ) from pydantic import ( ValidationError as _PydanticV2ValidationError, # pyright: ignore[reportMissingImports] ) except (ImportError, ModuleNotFoundError): # pragma: no cover _PydanticV2BaseModel = None # type: ignore[assignment,misc] _PydanticV2ValidationError = None # type: ignore[assignment,misc] # === Constrained Types === class StrictBool: """Only accepts actual bool values (not int 0/1).""" pass class PositiveInt: """Only accepts positive integers (> 0, not bool).""" pass class StrictFloat: """Only accepts actual float values (not int).""" pass # === Field Info === class FieldInfo: """Information about a schema field.""" __slots__ = ("default", "alias", "annotation") def __init__(self, default=..., *, alias=None): self.default = default self.alias = alias self.annotation: Any = None def is_required(self): return self.default is ... def Field(default=..., *, alias=None): """Create a field definition.""" return FieldInfo(default=default, alias=alias) # === Validation Error === class ValidationError(Exception): """Raised when schema validation fails.""" def __init__(self, error_list): self._errors = error_list msgs = "; ".join(e.get("msg", "") for e in error_list) super().__init__(msgs) def errors(self): return self._errors # === Schema === class _ValidatedResult: """Attribute-accessible result from model_validate.""" def __init__(self, data): self.__dict__.update(data) class Schema: """Base class for config validation schemas. Replaces pydantic.BaseModel.""" model_config: dict = {"extra": "allow", "arbitrary_types_allowed": True} model_fields: dict = {} def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) fields = {} all_hints = {} for base in reversed(cls.__mro__): base_annotations = getattr(base, "__annotations__", {}) all_hints.update(base_annotations) for name, annotation in all_hints.items(): if name in ("model_config", "model_fields") or name.startswith("_"): continue default = ... alias = None for klass in cls.__mro__: if name in klass.__dict__: val = klass.__dict__[name] if isinstance(val, FieldInfo): default = val.default alias = val.alias elif not isinstance( val, (type, classmethod, staticmethod, property) ): if not callable(val): default = val break field = FieldInfo(default=default, alias=alias) field.annotation = annotation fields[name] = field cls.model_fields = fields def __init__(self, **kwargs): for name, field in self.__class__.model_fields.items(): if name in kwargs: setattr(self, name, kwargs[name]) elif not field.is_required(): setattr(self, name, field.default) @classmethod def model_validate(cls, data): """Validate a dict against this schema.""" alias_gen = cls.model_config.get("alias_generator") errors = _validate_schema(data, cls.model_fields, cls.model_config, alias_gen) if errors: raise ValidationError(errors) # Build result with defaults filled in result_data = dict(data) for name, field in cls.model_fields.items(): data_key = name if field.alias is not None: data_key = field.alias elif alias_gen: data_key = alias_gen(name) if data_key not in result_data and not field.is_required(): result_data[data_key] = field.default return _ValidatedResult(result_data) @classmethod def from_function( cls, func, *, config=None, ): """Build a Schema subclass from a function's signature. Each parameter becomes a field. The annotation is used as the type (defaulting to ``Any`` when missing) and the default value is preserved (parameters without defaults become required fields). ``*args`` parameters are wrapped in ``Sequence[annotation]`` and stored under the ``VARIABLE_POSITIONAL_ARGS`` field name. Forward-reference annotations are resolved via ``typing.get_type_hints`` against the function's module namespace. """ from typing import Sequence as _Seq if config is None: config = {"extra": "forbid", "arbitrary_types_allowed": True} resolved = resolve_type_hints(func) fields = {} for param in inspect.signature(func).parameters.values(): annotation = resolved.get(param.name, param.annotation) if annotation is inspect.Parameter.empty: annotation = Any if param.default is inspect.Parameter.empty: default = ... else: default = param.default if param.kind == inspect.Parameter.VAR_POSITIONAL: annotation = _Seq[annotation] # type: ignore[valid-type] if param.kind == inspect.Parameter.VAR_KEYWORD: continue field = FieldInfo(default=default) field.annotation = annotation fields[param.name] = field return create_schema( func.__name__, __config__=config, **{name: (f.annotation, f) for name, f in fields.items()}, ) def model_dump(self): """Convert instance to dict.""" result = {} for name in self.__class__.model_fields: if hasattr(self, name): val = getattr(self, name) if isinstance(val, Schema): result[name] = val.model_dump() else: result[name] = val return result def create_schema(__name, __config__=None, **fields): """Dynamically create a Schema subclass. Each field value should be a (annotation, FieldInfo) tuple. """ if __config__ is None: __config__ = {"extra": "allow"} processed = {} annotations = {} defaults = {} for name, field_def in fields.items(): if isinstance(field_def, tuple) and len(field_def) == 2: annotation, field_info = field_def if not isinstance(field_info, FieldInfo): field_info = FieldInfo(default=field_info) else: raise ValueError(f"Field {name} must be (annotation, FieldInfo) tuple") field_info.annotation = annotation processed[name] = field_info annotations[name] = annotation if not field_info.is_required(): defaults[name] = field_info.default namespace = { "__annotations__": annotations, "model_config": __config__, } namespace.update(defaults) cls = type(__name, (Schema,), namespace) # Apply alias_generator to fields that don't have explicit aliases alias_gen = __config__.get("alias_generator") if __config__ else None if alias_gen and callable(alias_gen): for name, field in processed.items(): if field.alias is None: field.alias = alias_gen(name) # Override with our processed fields (preserving aliases) cls.model_fields = processed return cls # === Resolve forward references === def resolve_type_hints(func): """Resolve type hints for a function, handling forward references. Falls back to raw annotations if resolution fails. """ try: mod_name = getattr(func, "__module__", None) module = sys.modules.get(mod_name) if mod_name else None globalns = vars(module) if module else None return get_type_hints(func, globalns=globalns) except (NameError, AttributeError, TypeError, RecursionError): # NameError: unresolvable forward reference # AttributeError: module without expected attributes # TypeError: invalid annotation object # RecursionError: self-referential types (Python 3.13+) return {} # === Type Validation === def _error_type_for(annotation): """Get an error type string for an annotation.""" if annotation is int or annotation is PositiveInt: return "int_parsing" elif annotation is str: return "string_type" elif annotation is float or annotation is StrictFloat: return "float_parsing" elif annotation is bool or annotation is StrictBool: return "bool_type" return "value_error" def validate_type(value, annotation): """Validate value against a type annotation. Returns None if valid, or an error message string if invalid. """ ctx = Ctx() if _tc2_check_type(value, annotation, ctx=ctx): return None if ctx.errors: return str(ctx.errors[0]) return f"{value!r} does not match {annotation}" # pragma: no cover -- defensive fallback # === Schema Validation === def _validate_schema(data, fields, config, alias_generator=None): """Validate a data dict against schema fields. Returns list of error dicts (empty if valid). """ errors = [] extra_mode = config.get("extra", "allow") # Build mapping: data_key -> (field_name, FieldInfo) key_to_field = {} known_keys = set() for name, field in fields.items(): if field.alias is not None: data_key = field.alias elif alias_generator: data_key = alias_generator(name) else: data_key = name known_keys.add(data_key) key_to_field[data_key] = (name, field) # Check extra fields if extra_mode == "forbid": for key in data: if key not in known_keys: errors.append( { "loc": (key,), "msg": "Extra inputs are not permitted", "type": "extra_forbidden", } ) # Validate each field for data_key, (name, field) in key_to_field.items(): if data_key in data: value = data[data_key] err = validate_type(value, field.annotation) if err: errors.append( { "loc": (data_key,), "msg": err, "type": _error_type_for(field.annotation), } ) elif field.is_required(): errors.append( { "loc": (data_key,), "msg": "Field required", "type": "missing", } ) return errors # === Pydantic Compatibility Shim === _pydantic_cache: dict = {} def _get_pydantic_validation_error(): """Return the pydantic ValidationError class(es) to catch. Tries both pydantic.v1 and pydantic so we catch the right exception regardless of which API the caller's model was built with. """ errors = [] if _PydanticV1ValidationError is not None: errors.append(_PydanticV1ValidationError) if _PydanticV2ValidationError is not None: errors.append(_PydanticV2ValidationError) if errors: return tuple(errors) # Should never happen — we only get here if someone passed a pydantic # model, which means pydantic is installed. Fall back to Exception so # the except clause still works rather than crashing. return (Exception,) # pragma: no cover def _is_pydantic_model(cls): """Check if cls is a pydantic BaseModel class (v1 or v2) without hard-depending on pydantic. Returns False if pydantic is not installed.""" if not isinstance(cls, type): return False if issubclass(cls, Schema): return False if _PydanticV1BaseModel is not None and issubclass(cls, _PydanticV1BaseModel): return True if _PydanticV2BaseModel is not None and issubclass(cls, _PydanticV2BaseModel): return True return False def _pydantic_instance_to_dict(obj): """Convert a pydantic model instance to a dict.""" if hasattr(obj, "model_dump"): return obj.model_dump() if hasattr(obj, "dict"): return obj.dict() return obj def _extract_pydantic_fields(pydantic_cls): """Extract field definitions from a pydantic BaseModel class (v1 or v2).""" fields = {} if hasattr(pydantic_cls, "model_fields"): # pydantic v2 interface (check first — v2 also exposes __fields__ # as a deprecated shim, so we must not fall into the v1 branch) for name, pyd_field in pydantic_cls.model_fields.items(): annotation = pyd_field.annotation if pyd_field.is_required(): default = ... else: default = pyd_field.default alias = pyd_field.alias if isinstance(annotation, type) and _is_pydantic_model(annotation): annotation = ensure_schema(annotation) if default is not ... and hasattr(default, "model_dump"): default = _pydantic_instance_to_dict(default) field = FieldInfo(default=default, alias=alias) field.annotation = annotation fields[name] = field elif hasattr(pydantic_cls, "__fields__"): # pydantic v1 interface for name, pyd_field in pydantic_cls.__fields__.items(): annotation = pyd_field.outer_type_ # pydantic v1 unwraps Optional[X] into outer_type_=X + # allow_none=True. Re-wrap so our validator sees the Union. if getattr(pyd_field, "allow_none", False): annotation = Optional[annotation] if pyd_field.required: default = ... else: default = pyd_field.default alias = pyd_field.alias if pyd_field.alias != name else None # Recursively convert nested pydantic model annotations if isinstance(annotation, type) and _is_pydantic_model(annotation): annotation = ensure_schema(annotation) # Convert pydantic instance defaults to dicts if default is not ... and hasattr(default, "__fields__"): default = _pydantic_instance_to_dict(default) field = FieldInfo(default=default, alias=alias) field.annotation = annotation fields[name] = field return fields def _extract_pydantic_config(pydantic_cls): """Extract model config from a pydantic BaseModel class (v1 or v2).""" config = {"extra": "allow"} if hasattr(pydantic_cls, "__config__"): # pydantic v1: inner class Config cfg = pydantic_cls.__config__ extra = getattr(cfg, "extra", "allow") # v1 may use an enum (e.g. Extra.forbid); extract the .value if hasattr(extra, "value"): extra = extra.value # pyright: ignore[reportAttributeAccessIssue] config["extra"] = extra if isinstance(extra, str) else str(extra) if hasattr(cfg, "arbitrary_types_allowed"): config["arbitrary_types_allowed"] = cfg.arbitrary_types_allowed elif hasattr(pydantic_cls, "model_config") and isinstance( pydantic_cls.model_config, dict ): # pydantic v2: dict config = dict(pydantic_cls.model_config) return config def ensure_schema(schema_cls): """Ensure *schema_cls* satisfies the Schema interface. If it already is a Schema subclass, return it unchanged. If it is a pydantic BaseModel (v1 or v2), build a thin Schema wrapper that exposes the same ``model_fields`` / ``model_config`` and delegates ``model_validate`` to the original pydantic class so that pydantic validators, strict types, constrained types etc. keep working. This allows downstream libraries (spaCy, thinc, …) to keep passing pydantic schemas to ``registry.resolve()`` / ``registry.fill()`` even though confection itself no longer depends on pydantic. """ if isinstance(schema_cls, type) and issubclass(schema_cls, Schema): return schema_cls if not _is_pydantic_model(schema_cls): return schema_cls # Return cached conversion if available if schema_cls in _pydantic_cache: return _pydantic_cache[schema_cls] fields = _extract_pydantic_fields(schema_cls) config = _extract_pydantic_config(schema_cls) # Build wrapper class that inherits from Schema pyd_cls = schema_cls # capture for closure wrapper = type(pydantic_cls_name(schema_cls), (Schema,), {}) wrapper.model_fields = fields wrapper.model_config = config # Delegate model_validate to the original pydantic model so that # pydantic-level validators / strict types / constraints keep working. @classmethod # type: ignore[misc] def _pydantic_model_validate(cls, data): # Resolve the concrete pydantic ValidationError class once so the # except clause is as narrow as possible. pyd_validation_err = _get_pydantic_validation_error() try: if hasattr(pyd_cls, "model_validate"): pyd_cls.model_validate(data) elif hasattr(pyd_cls, "parse_obj"): pyd_cls.parse_obj(data) else: # pragma: no cover -- all pydantic versions have model_validate or parse_obj pyd_cls(**data) # pragma: no cover except pyd_validation_err as e: raise ValidationError( e.errors() # pyright: ignore[reportAttributeAccessIssue] ) from None # Return attribute-accessible result with defaults filled in result_data = dict(data) for name, field in cls.model_fields.items(): data_key = field.alias if field.alias is not None else name if data_key not in result_data and not field.is_required(): result_data[data_key] = field.default return _ValidatedResult(result_data) wrapper.model_validate = _pydantic_model_validate _pydantic_cache[schema_cls] = wrapper return wrapper def pydantic_cls_name(cls): return getattr(cls, "__name__", "PydanticSchema") explosion-confection-a98f9d6/pyproject.toml000066400000000000000000000005501516055431200212310ustar00rootroot00000000000000[build-system] requires = [ "setuptools", ] build-backend = "setuptools.build_meta" [tool.ruff] line-length = 88 [tool.ruff.lint] select = ["E", "F", "W", "I"] ignore = ["E203", "E501", "E402", "E731"] [tool.ruff.lint.isort] known-first-party = ["confection"] [tool.pyright] include = ["confection"] exclude = ["confection/tests"] pythonVersion = "3.10" explosion-confection-a98f9d6/pyrightconfig.json000066400000000000000000000001131516055431200220570ustar00rootroot00000000000000{ "exclude": ["confection/tests"], "reportMissingImports": "warning" } explosion-confection-a98f9d6/requirements.txt000066400000000000000000000002601516055431200215770ustar00rootroot00000000000000# Development/test requirements catalogue>=2.0.3,<2.1.0 pathy>=0.3.5 pytest>=5.2.0,!=7.1.0 numpy>=1.15.0 hypothesis pydantic>=1.7.4,<3.0.0 # for ensure_schema shim tests ruff explosion-confection-a98f9d6/setup.cfg000066400000000000000000000020731516055431200201400ustar00rootroot00000000000000[metadata] version = 1.3.3 description = The sweetest config system for Python url = https://github.com/explosion/confection author = Explosion author_email = contact@explosion.ai license = MIT long_description = file: README.md long_description_content_type = text/markdown classifiers = Development Status :: 5 - Production/Stable Environment :: Console Intended Audience :: Developers Intended Audience :: Science/Research License :: OSI Approved :: MIT License Operating System :: POSIX :: Linux Operating System :: MacOS :: MacOS X Operating System :: Microsoft :: Windows Programming Language :: Python :: 3 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 Programming Language :: Python :: 3.14 Topic :: Scientific/Engineering [options] zip_safe = true include_package_data = true python_requires = >=3.10 install_requires = typing_extensions>=4.0,<5.0; python_version < "3.11" [sdist] formats = gztar explosion-confection-a98f9d6/setup.py000066400000000000000000000002321516055431200200240ustar00rootroot00000000000000#!/usr/bin/env python if __name__ == "__main__": from setuptools import find_packages, setup setup(name="confection", packages=find_packages()) explosion-confection-a98f9d6/tests/000077500000000000000000000000001516055431200174575ustar00rootroot00000000000000explosion-confection-a98f9d6/tests/conftest.py000066400000000000000000000000001516055431200216440ustar00rootroot00000000000000explosion-confection-a98f9d6/tests/strategies.py000066400000000000000000000214251516055431200222070ustar00rootroot00000000000000import json from hypothesis import strategies as st # Valid config keys: simple identifiers, no dots or special configparser chars config_keys = st.from_regex(r"[a-z][a-z0-9_]{0,15}", fullmatch=True) # Strings safe for config values (no $ to avoid interpolation, no " or \ to # avoid JSON escaping issues) config_strings = st.text( st.characters(whitelist_categories=("L", "N", "Z"), blacklist_characters='$"\\'), min_size=0, max_size=20, ) # Scalar leaf values: str, int, float, bool, None scalar_leaves = st.one_of( config_strings, st.integers(min_value=-(2**31), max_value=2**31), st.floats(allow_nan=False, allow_infinity=False), st.booleans(), st.none(), ) # JSON leaf values: scalars, or lists/dicts of scalars (arbitrarily nested). # These are values that get serialized as a single JSON-encoded string in the # config, rather than expanded into subsections. _json_values = st.recursive( scalar_leaves, lambda children: st.one_of( st.lists(children, max_size=5), st.dictionaries(config_keys, children, max_size=5), ), max_leaves=10, ) # Only the non-scalar cases are interesting as "json leaves" — scalars are # already covered by scalar_leaves. json_leaves = st.one_of( st.lists(scalar_leaves, min_size=0, max_size=5), st.dictionaries(config_keys, _json_values, min_size=0, max_size=5), ) # Basic config leaves (no JSON-encoded collections) config_leaves = scalar_leaves # A config node is either a leaf or a dict of config nodes. config_nodes = st.recursive( config_leaves, lambda children: st.dictionaries(config_keys, children, min_size=0, max_size=4), max_leaves=15, ) # A valid config must have sections at the top level (all values must be dicts). config_dicts = st.dictionaries( config_keys, st.dictionaries(config_keys, config_nodes, min_size=0, max_size=5), min_size=1, max_size=5, ) def _leaf_to_str(value): """Serialize a leaf value the way confection's config format expects.""" if value is None: return "null" elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, str): return json.dumps(value) elif isinstance(value, float): return json.dumps(value) elif isinstance(value, int): return str(value) elif isinstance(value, (list, dict)): return json.dumps(value) raise TypeError(f"Unexpected leaf type: {type(value)}") def _flatten_sections(data, prefix="", inline_paths=frozenset()): """Convert a nested dict into a list of (section_path, {key: leaf_str}) pairs. Dicts at paths in inline_paths are serialized as inline JSON values rather than expanded into subsections. """ sections = [] leaves = {} for key, value in data.items(): key_path = f"{prefix}.{key}" if prefix else key if isinstance(value, dict) and key_path not in inline_paths: sections.extend(_flatten_sections(value, key_path, inline_paths)) else: leaves[key] = _leaf_to_str(value) if prefix: sections.insert(0, (prefix, leaves)) return sections def serialize_with_inline(data, inline_paths=frozenset()): """Serialize a nested dict to a config string, inlining dicts at the given paths.""" sections = _flatten_sections(data, inline_paths=inline_paths) parts = [] for section_name, leaves in sections: parts.append(f"[{section_name}]") for key, value_str in leaves.items(): parts.append(f"{key} = {value_str}") parts.append("") return "\n".join(parts).strip() @st.composite def json_config_dicts(draw): """Strategy that produces (data, inline_paths) pairs. The data is a nested dict suitable for Config. inline_paths is a set of dotted paths where the value is a JSON-encoded leaf (list or dict) rather than a config subsection. This distinction matters for serialization: the library always expands dicts into subsections, but a valid config string could also have them as inline JSON values. """ # Start with a basic config dict (sections with scalar leaves) base = draw(config_dicts) inline_paths = set() # Sprinkle some JSON leaf values into the config for section_key, section in list(base.items()): for key in list(section.keys()): if draw(st.booleans()): # Replace some leaves with JSON-encoded values value = draw(json_leaves) section[key] = value inline_paths.add(f"{section_key}.{key}") return base, inline_paths def _collect_scalar_paths(data, prefix=""): """Collect all (dotted_path, value) pairs for scalar leaves in a config dict.""" paths = [] for key, value in data.items(): path = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): paths.extend(_collect_scalar_paths(value, path)) elif not isinstance(value, (list, dict)): paths.append((path, value)) return paths def _set_at_path(data, path, value): """Set a value at a dotted path in a nested dict.""" parts = path.split(".") node = data for part in parts[:-1]: node = node[part] node[parts[-1]] = value def _deep_copy(obj): """Simple deep copy for nested dicts/lists of scalars.""" if isinstance(obj, dict): return {k: _deep_copy(v) for k, v in obj.items()} elif isinstance(obj, list): return [_deep_copy(v) for v in obj] return obj def _make_interpolated_config_str(sections, replacements): """Build a config string from sections, applying variable replacements.""" modified = {name: dict(leaves) for name, leaves in sections} for replace_path, target_path in replacements.items(): section, key = replace_path.rsplit(".", 1) if section in modified: modified[section][key] = "${" + target_path + "}" parts = [] for section_name, _ in sections: parts.append(f"[{section_name}]") for key, val in modified.get(section_name, {}).items(): parts.append(f"{key} = {val}") parts.append("") return "\n".join(parts).strip() @st.composite def interpolated_config(draw): """Strategy producing (config_str, expected_dict) with variable interpolation. Replaces some scalar leaves with ${section.key} references. Avoids cycles by only referencing values that are never themselves replaced. """ base = draw(config_dicts) scalar_paths = _collect_scalar_paths(base) # Filter out bools — they serialize as true/false which can't be # referenced via ${} (configparser treats them specially) scalar_paths = [(p, v) for p, v in scalar_paths if not isinstance(v, bool)] if len(scalar_paths) < 2: return serialize_with_inline(base), base sections = _flatten_sections(base) expected = _deep_copy(base) # Split paths into targets (stable values to reference) and candidates # (values that may be replaced with refs). A path can't be both. # Use random subset selection instead of permutations (which is O(n!)). target_flags = draw( st.lists( st.booleans(), min_size=len(scalar_paths), max_size=len(scalar_paths), ) ) targets = [sp for sp, flag in zip(scalar_paths, target_flags) if flag] candidates = [sp for sp, flag in zip(scalar_paths, target_flags) if not flag] if not targets or not candidates: return serialize_with_inline(base), base replacements = {} for cand_path, _ in candidates: if draw(st.booleans()): target_path, target_value = draw(st.sampled_from(targets)) replacements[cand_path] = target_path _set_at_path(expected, cand_path, target_value) config_str = _make_interpolated_config_str(sections, replacements) return config_str, expected @st.composite def circular_interpolated_config(draw): """Strategy producing config strings with circular variable references.""" base = draw(config_dicts) scalar_paths = _collect_scalar_paths(base) scalar_paths = [(p, v) for p, v in scalar_paths if not isinstance(v, bool)] if len(scalar_paths) < 2: from hypothesis import assume assume(False) sections = _flatten_sections(base) # Pick 2+ paths and create a cycle: a -> b -> ... -> a cycle_len = draw(st.integers(min_value=2, max_value=min(4, len(scalar_paths)))) cycle_indices = draw( st.lists( st.sampled_from(range(len(scalar_paths))), min_size=cycle_len, max_size=cycle_len, unique=True, ) ) cycle_paths = [scalar_paths[i][0] for i in cycle_indices] replacements = {} for i in range(cycle_len): replacements[cycle_paths[i]] = cycle_paths[(i + 1) % cycle_len] return _make_interpolated_config_str(sections, replacements) explosion-confection-a98f9d6/tests/test_config_basic.py000066400000000000000000000115031516055431200234760ustar00rootroot00000000000000"""Test basic config sections. * No variable interpolation * No promises Just basic structure and JSON-encoded values. """ from configparser import InterpolationDepthError import pytest from hypothesis import HealthCheck, given, settings from confection import Config from tests.strategies import ( circular_interpolated_config, config_dicts, interpolated_config, json_config_dicts, serialize_with_inline, ) @given(config_dicts) def test_roundtrip(data): """Config.from_str(config.to_str()) should reproduce the original dict.""" config = Config(data) serialized = config.to_str(interpolate=False) restored = Config().from_str(serialized, interpolate=False) assert dict_equal(restored, data) @given(json_config_dicts()) def test_json_leaves_parse(pair): """Config strings with inline JSON values (lists, dicts) should parse to the same nested dict structure.""" data, inline_paths = pair config_str = serialize_with_inline(data, inline_paths) restored = Config().from_str(config_str, interpolate=False) assert dict_equal(restored, data) @given(interpolated_config()) def test_variable_interpolation(pair): """Config strings with ${section.key} variable references should resolve to the referenced values after interpolation.""" config_str, expected = pair restored = Config().from_str(config_str, interpolate=True) assert dict_equal(restored, expected) @settings(suppress_health_check=[HealthCheck.too_slow]) @given(circular_interpolated_config()) def test_circular_interpolation_raises(config_str): """Circular variable references should raise an error.""" with pytest.raises((InterpolationDepthError, Exception)): Config().from_str(config_str, interpolate=True) def test_star_sections_parse(): """[section.*.name] creates a dict under the "*" key.""" result = Config().from_str( """ [section] [section.*.first] x = 1 [section.*.second] x = 2 """, interpolate=False, ) assert result["section"]["*"] == {"first": {"x": 1}, "second": {"x": 2}} def test_star_sections_roundtrip(): """Configs with * sections roundtrip through to_str/from_str.""" original = Config().from_str( """ [section] [section.*.a] x = 1 y = "hello" [section.*.b] x = 2 y = "world" """, interpolate=False, ) serialized = original.to_str(interpolate=False) restored = Config().from_str(serialized, interpolate=False) assert dict_equal(restored, original) def test_star_sections_nested(): """* sections can appear at different levels of nesting.""" result = Config().from_str( """ [top] [top.*.item] val = 1 [top.*.item.sub] val = 2 """, interpolate=False, ) assert result["top"]["*"]["item"]["val"] == 1 assert result["top"]["*"]["item"]["sub"] == {"val": 2} def test_star_with_interpolation(): """Variable interpolation works across * sections.""" result = Config().from_str( """ [settings] lr = 0.001 [models] [models.*.first] learning_rate = ${settings.lr} """, interpolate=True, ) assert result["models"]["*"]["first"]["learning_rate"] == 0.001 @pytest.mark.parametrize( "text,expected_value,expected_type", [ ("True", True, bool), ("False", False, bool), ("true", True, bool), ("false", False, bool), ("None", None, type(None)), ("null", None, type(None)), ], ) def test_python_style_literals(text, expected_value, expected_type): """Python-style True/False/None must be parsed the same as JSON-style true/false/null. Many existing spaCy configs use the capitalised form.""" cfg = f"[section]\nval = {text}\n" config = Config().from_str(cfg) result = config["section"]["val"] assert result is expected_value, ( f"Expected {expected_value!r} ({expected_type.__name__}), " f"got {result!r} ({type(result).__name__})" ) assert type(result) is expected_type @pytest.mark.parametrize("text", ['"True"', '"False"', '"None"']) def test_quoted_python_literals_stay_strings(text): """JSON-quoted strings like '"True"' must remain strings, not be coerced to booleans/None.""" cfg = f"[section]\nval = {text}\n" config = Config().from_str(cfg) result = config["section"]["val"] assert isinstance(result, str), ( f"Expected str, got {type(result).__name__}: {result!r}" ) def dict_equal(a, b) -> bool: """Recursively compare two nested dicts, treating empty dicts as equal.""" if type(a) is not type(b) and not (isinstance(a, dict) and isinstance(b, dict)): return a == b if isinstance(a, dict): all_keys = set(a) | set(b) for key in all_keys: av = a.get(key, {}) bv = b.get(key, {}) if not dict_equal(av, bv): return False return True return a == b explosion-confection-a98f9d6/tests/test_config_errors.py000066400000000000000000000130061516055431200237310ustar00rootroot00000000000000"""Test error cases in config parsing and validation.""" import pytest from confection import Config from confection._errors import ConfigValidationError def test_malformed_config_string(): """Malformed config syntax should raise ConfigValidationError.""" with pytest.raises(ConfigValidationError, match="formatted correctly"): Config().from_str("[[invalid", interpolate=False) def test_malformed_missing_section_header(): """Values without any section header are malformed.""" with pytest.raises(ConfigValidationError, match="formatted correctly"): Config().from_str("key = value", interpolate=False) def test_top_level_values_without_section(): """Top-level values that leak into DEFAULT should be caught.""" # configparser treats values before any section header as defaults. # This is caught by read_string as a MissingSectionHeaderError -> ParsingError. with pytest.raises(ConfigValidationError): Config().from_str("x = 1\n[section]\ny = 2", interpolate=False) def test_missing_parent_section(): """Dotted section names require all parent sections to exist.""" with pytest.raises(ConfigValidationError, match="not defined"): Config().from_str("[a.b.c]\nx = 1", interpolate=False) def test_missing_intermediate_section(): """[a] and [a.b.c] without [a.b] should fail.""" with pytest.raises(ConfigValidationError, match="not defined"): Config().from_str("[a]\nx = 1\n\n[a.b.c]\ny = 2", interpolate=False) def test_key_subsection_conflict(): """A key that conflicts with a subsection name should be caught.""" with pytest.raises(ConfigValidationError, match="conflicting"): Config().from_str("[a]\nb = 1\n\n[a.b]\nc = 2", interpolate=False) def test_override_without_dot(): """Overrides must have a dotted path (section.key).""" with pytest.raises(ConfigValidationError, match="overrid"): Config().from_str("[a]\nx = 1", interpolate=False, overrides={"x": 2}) def test_override_nonexistent_section(): """Overrides for nonexistent sections should fail.""" with pytest.raises(ConfigValidationError, match="overrid"): Config().from_str("[a]\nx = 1", interpolate=False, overrides={"b.x": 2}) def test_override_applies(): """Valid overrides should replace values.""" result = Config().from_str("[a]\nx = 1", interpolate=False, overrides={"a.x": 99}) assert result["a"]["x"] == 99 def test_interpolation_missing_variable(): """Referencing a nonexistent variable should raise.""" with pytest.raises(Exception): Config().from_str("[a]\nx = ${b.y}", interpolate=True) def test_section_ref_in_string(): """Referencing a whole section inside a string should raise.""" with pytest.raises(ConfigValidationError, match="Can't reference whole sections"): Config().from_str( '[defaults]\nlr = 0.001\n\n[a]\nx = "hello ${defaults}"', interpolate=True, ) def test_section_reference_resolves(): """${section} references should resolve to the section dict.""" result = Config().from_str( "[defaults]\nlr = 0.001\n\n[a]\nsettings = ${defaults}", interpolate=True, ) assert result["a"]["settings"] == {"lr": 0.001} def test_override_replaces_promise_section(): """Overriding a promise section with a different promise should replace it.""" result = Config().from_str( """ [a] [a.scorer] @scorers = "old_scorer.v1" """, interpolate=False, overrides={"a.scorer": {"@scorers": "new_scorer.v1"}}, ) assert result["a"]["scorer"]["@scorers"] == "new_scorer.v1" def test_uninterpolated_variable_preserved(): """With interpolate=False, variable references should stay as strings.""" result = Config().from_str("[a]\nx = 1\n\n[b]\ny = ${a.x}", interpolate=False) assert result["b"]["y"] == "${a.x}" def test_single_quoted_string_warns(): """Single-quoted values should emit a warning about JSON formatting.""" import warnings with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") Config().from_str("[a]\nx = 'hello'", interpolate=True) assert any("single-quoted" in str(warning.message) for warning in w) def test_dollar_dollar_escape(): """$$ in config values should produce a literal $.""" result = Config().from_str('[a]\nx = "$$100"', interpolate=True) assert result["a"]["x"] == "$100" def test_bad_interpolation_syntax(): """Malformed ${...} reference should raise.""" with pytest.raises(Exception): Config().from_str("[a]\nx = ${", interpolate=True) def test_bare_dollar_raises(): """A bare $ not followed by $ or { should raise.""" with pytest.raises(Exception): Config().from_str('[a]\nx = "$x"', interpolate=True) def test_same_section_variable(): """${key} without a section prefix references the same section.""" result = Config().from_str("[a]\nx = 1\ny = ${x}", interpolate=True) assert result["a"]["y"] == 1 def test_string_interpolation_coerces_values(): """Non-string values interpolated into strings should be coerced.""" result = Config().from_str( '[a]\nx = 42\n\n[b]\ny = "value is ${a.x}"', interpolate=True, ) assert result["b"]["y"] == "value is 42" def test_string_interpolation_unwraps_json_strings(): """JSON strings interpolated into compound expressions should be unwrapped.""" result = Config().from_str( '[a]\nx = "hello"\n\n[b]\ny = "${a.x} world"', interpolate=True, ) assert result["b"]["y"] == "hello world" explosion-confection-a98f9d6/tests/test_config_serialization.py000066400000000000000000000150531516055431200252760ustar00rootroot00000000000000"""Test Config serialization methods: to_str, from_str, to_bytes, from_bytes, to_disk, from_disk, copy, merge, interpolate.""" import pytest from confection import Config from confection._errors import ConfectionError # -- to_str / from_str basics (beyond the hypothesis tests) -- def test_to_str_from_str_roundtrip(): data = {"training": {"lr": 0.001, "epochs": 10}, "model": {"name": "cnn"}} config = Config(data) restored = Config().from_str(config.to_str(interpolate=False), interpolate=False) assert dict(restored["training"]) == {"lr": 0.001, "epochs": 10} assert dict(restored["model"]) == {"name": "cnn"} def test_empty_config(): config = Config() assert config.to_str(interpolate=False) == "" restored = Config().from_str("", interpolate=False) assert dict(restored) == {} # -- to_bytes / from_bytes -- def test_to_bytes(): config = Config({"a": {"x": 1}}) b = config.to_bytes(interpolate=False) assert isinstance(b, bytes) assert b"[a]" in b assert b"x = 1" in b def test_from_bytes_roundtrip(): config = Config({"a": {"x": 1, "y": "hello"}}) b = config.to_bytes(interpolate=False) restored = Config().from_bytes(b, interpolate=False) assert restored["a"]["x"] == 1 assert restored["a"]["y"] == "hello" def test_from_bytes_with_overrides(): config = Config({"a": {"x": 1}}) b = config.to_bytes(interpolate=False) restored = Config().from_bytes(b, interpolate=False, overrides={"a.x": 99}) assert restored["a"]["x"] == 99 # -- to_disk / from_disk -- def test_to_disk_from_disk_roundtrip(tmp_path): config = Config({"section": {"key": "value", "num": 42}}) path = tmp_path / "config.cfg" config.to_disk(path, interpolate=False) assert path.exists() restored = Config().from_disk(path, interpolate=False) assert restored["section"]["key"] == "value" assert restored["section"]["num"] == 42 def test_to_disk_str_path(tmp_path): config = Config({"a": {"x": 1}}) path = str(tmp_path / "config.cfg") config.to_disk(path, interpolate=False) restored = Config().from_disk(path, interpolate=False) assert restored["a"]["x"] == 1 def test_from_disk_with_overrides(tmp_path): config = Config({"a": {"x": 1}}) path = tmp_path / "config.cfg" config.to_disk(path, interpolate=False) restored = Config().from_disk(path, interpolate=False, overrides={"a.x": 99}) assert restored["a"]["x"] == 99 # -- copy -- def test_copy_is_deep(): config = Config({"a": {"x": [1, 2, 3]}}) copied = config.copy() copied["a"]["x"].append(4) assert config["a"]["x"] == [1, 2, 3] assert copied["a"]["x"] == [1, 2, 3, 4] def test_copy_preserves_metadata(): config = Config({"a": {"x": 1}}, is_interpolated=False, section_order=["a"]) copied = config.copy() assert copied.is_interpolated is False assert copied.section_order == ["a"] # -- interpolate -- def test_interpolate(): config = Config().from_str( "[a]\nx = 1\n\n[b]\ny = ${a.x}", interpolate=False, ) assert config["b"]["y"] == "${a.x}" interpolated = config.interpolate() assert interpolated["b"]["y"] == 1 # Original should be unchanged assert config["b"]["y"] == "${a.x}" def test_interpolate_returns_new_config(): config = Config().from_str("[a]\nx = 1", interpolate=False) interpolated = config.interpolate() assert interpolated is not config # -- merge -- def test_merge_basic(): base = Config({"a": {"x": 1, "y": 2}}) updates = {"a": {"x": 99}} merged = base.merge(updates) assert merged["a"]["x"] == 99 assert merged["a"]["y"] == 2 def test_merge_adds_new_keys(): base = Config({"a": {"x": 1}}) updates = {"a": {"x": 1, "y": 2}} merged = base.merge(updates) assert merged["a"]["y"] == 2 def test_merge_remove_extra(): """remove_extra filters keys from updates that aren't in defaults.""" base = Config({"a": {"x": 1}}) updates = {"a": {"x": 99, "extra": "gone"}} merged = base.merge(updates, remove_extra=True) assert merged["a"]["x"] == 99 assert "extra" not in merged["a"] def test_merge_does_not_mutate_original(): base = Config({"a": {"x": 1}}) updates = {"a": {"x": 99}} base.merge(updates) assert base["a"]["x"] == 1 def test_merge_deep(): base = Config({"a": {"sub": {"x": 1, "y": 2}}}) updates = {"a": {"sub": {"x": 99}}} merged = base.merge(updates) assert merged["a"]["sub"]["x"] == 99 assert merged["a"]["sub"]["y"] == 2 # -- __init__ -- def test_init_from_dict(): config = Config({"a": {"x": 1}}) assert config["a"]["x"] == 1 assert config.is_interpolated is True def test_init_from_config(): original = Config({"a": {"x": 1}}, is_interpolated=False, section_order=["a"]) copy = Config(original) assert copy["a"]["x"] == 1 assert copy.is_interpolated is False assert copy.section_order == ["a"] def test_init_bad_data(): with pytest.raises(ConfectionError, match="Expected dict"): Config([1, 2, 3]) def test_init_override_metadata(): original = Config({"a": {"x": 1}}, is_interpolated=False) copy = Config(original, is_interpolated=True) assert copy.is_interpolated is True # -- overrides with interpolation -- # -- deep_merge_configs edge cases -- def test_merge_leaf_vs_dict(): """When config has a leaf where defaults has a dict, skip merging.""" base = Config({"a": {"sub": {"x": 1}}}) updates = Config({"a": {"sub": "leaf_value"}}) merged = base.merge(updates) assert merged["a"]["sub"] == "leaf_value" def test_merge_different_promise(): """Blocks with different @registry functions should not merge.""" base = Config({"a": {"@cats": "meow.v1", "x": 1}}) updates = Config({"a": {"@cats": "woof.v1", "y": 2}}) merged = base.merge(updates) # Updates win — base defaults not merged in because different function assert merged["a"]["@cats"] == "woof.v1" assert merged["a"]["y"] == 2 assert "x" not in merged["a"] def test_merge_promise_not_in_defaults(): """Promise in updates but not in defaults should not merge defaults.""" base = Config({"a": {"x": 1}}) updates = Config({"a": {"@cats": "meow.v1", "y": 2}}) merged = base.merge(updates) assert merged["a"]["@cats"] == "meow.v1" assert "x" not in merged["a"] # -- overrides with interpolation -- def test_overrides_with_interpolation(): result = Config().from_str( "[a]\nx = 1\n\n[b]\ny = ${a.x}", interpolate=True, overrides={"a.x": 42}, ) assert result["a"]["x"] == 42 assert result["b"]["y"] == 42 explosion-confection-a98f9d6/tests/test_errors.py000066400000000000000000000012471516055431200224100ustar00rootroot00000000000000"""Test _errors.py edge cases.""" from confection._errors import ConfigValidationError def test_from_error(): original = ConfigValidationError( errors=[{"loc": ["a"], "msg": "bad"}], title="Original", desc="original desc", ) new = ConfigValidationError.from_error(original, title="New title") assert new.title == "New title" assert new.desc == "original desc" assert new.errors == original.errors def test_format_with_parent(): err = ConfigValidationError( errors=[{"loc": ["x"], "msg": "bad"}], title="Error", parent="section", ) assert "section" in err.text assert "-> x" in err.text explosion-confection-a98f9d6/tests/test_issue52.py000066400000000000000000000014021516055431200223640ustar00rootroot00000000000000"""Test for issue #52: Config with `*` (positional) args should roundtrip to str and back. """ from confection import Config def test_config_star_args_to_str(): """Config.to_str() should not crash when sections contain * args.""" test_cfg = """[test] foo = "bar" [test.*] bar = "foo" """ cfg = Config().from_str(test_cfg) # This should not raise NoSectionError result = cfg.to_str() assert "[test]" in result def test_config_star_args_roundtrip(): """Config with * args should survive a from_str -> to_str -> from_str cycle.""" test_cfg = """[test] @cats = "catsie.v1" evil = false [test.*.foo] x = 1 """ cfg = Config().from_str(test_cfg) cfg_str = cfg.to_str() cfg2 = Config().from_str(cfg_str) assert cfg == cfg2 explosion-confection-a98f9d6/tests/test_issue54.py000066400000000000000000000027331516055431200223760ustar00rootroot00000000000000"""Test for issue #54: Error when pydantic validator coerces str to dict. A custom type with __get_validators__ that converts a string to a dict should not crash _update_from_parsed. """ import sys import pytest pydantic = pytest.importorskip("pydantic") if sys.version_info >= (3, 14): pytest.skip( "pydantic v1 is not compatible with Python 3.14+", allow_module_level=True ) try: from pydantic.v1 import BaseModel except ImportError: from pydantic import BaseModel # type: ignore from confection import Config, ConfigValidationError, registry class CastStrAsDict: @classmethod def validate(cls, value): if isinstance(value, str): return {value: True} return value @classmethod def __get_validators__(cls): yield cls.validate class SectionSchema(BaseModel): value: CastStrAsDict class MainSchema(BaseModel): section: SectionSchema def test_fill_with_validator_str_to_dict(): """fill() should not crash when a validator coerces str -> dict.""" cfg = Config().from_str( """ [section] value = "ok" """ ) # This should not raise TypeError: string indices must be integers try: result = registry.fill(cfg, schema=MainSchema) # If it succeeds, the section should still be present assert "section" in result except ConfigValidationError: # A validation error is acceptable (the type is unusual), # but TypeError is not. pass explosion-confection-a98f9d6/tests/test_issue58.py000066400000000000000000000024341516055431200224000ustar00rootroot00000000000000"""Test for issue #58: Registered functions with pydantic BaseModel arguments should receive constructed model instances, not raw dicts. """ import sys import catalogue import pytest pydantic = pytest.importorskip("pydantic") if sys.version_info >= (3, 14): pytest.skip( "pydantic v1 is not compatible with Python 3.14+", allow_module_level=True ) try: from pydantic.v1 import BaseModel except ImportError: from pydantic import BaseModel # type: ignore from confection import Config, registry class Resource(BaseModel): url: str @pytest.fixture(autouse=True) def _register(): if not hasattr(registry, "resources"): registry.resources = catalogue.create( "confection", "resources", entry_points=False ) registry.resources.register("Resource.v1", func=make_resource) yield def make_resource(resource: Resource) -> str: return resource.url def test_basemodel_arg_receives_model_instance(): """A registered function with a BaseModel param should receive the constructed model, not a plain dict.""" config = Config().from_str( """ [test] @resources = "Resource.v1" [test.resource] url = "http://example.com" """ ) result = registry.resolve(config) assert result["test"] == "http://example.com" explosion-confection-a98f9d6/tests/test_issue59.py000066400000000000000000000041261516055431200224010ustar00rootroot00000000000000"""Test for issue #59: Registered functions with pydantic class arguments should receive constructed model instances, not have them flattened to dicts. This is closely related to #58 but tests the case where the BaseModel arg is itself resolved from a registered function (nested promise). """ import sys import catalogue import pytest pydantic = pytest.importorskip("pydantic") if sys.version_info >= (3, 14): pytest.skip( "pydantic v1 is not compatible with Python 3.14+", allow_module_level=True ) try: from pydantic.v1 import BaseModel except ImportError: from pydantic import BaseModel # type: ignore from confection import Config, registry class Dim(BaseModel): length: int @pytest.fixture(autouse=True) def _register(): if not hasattr(registry, "dims"): registry.dims = catalogue.create("confection", "dims", entry_points=False) if not hasattr(registry, "architectures"): registry.architectures = catalogue.create( "confection", "architectures", entry_points=False ) registry.dims.register("Dim.v1", func=make_dim) registry.architectures.register("arch.v1", func=load_architecture) yield def make_dim(length: int) -> Dim: return Dim(length=length) def load_architecture(foo: str, dim: Dim) -> str: # dim should be a Dim instance, not a dict return f"{foo}:{dim.length}" def test_nested_basemodel_arg(): """A registered function receiving a BaseModel resolved from another registered function should get the model instance.""" config = Config().from_str( """ [model] @architectures = "arch.v1" foo = "test" [model.dim] @dims = "Dim.v1" length = 6 """ ) result = registry.resolve(config) assert result["model"] == "test:6" def test_direct_basemodel_arg(): """A registered function receiving a BaseModel from config keys (not a promise) should get the model instance.""" config = Config().from_str( """ [model] @architectures = "arch.v1" foo = "test" [model.dim] length = 6 """ ) result = registry.resolve(config) assert result["model"] == "test:6" explosion-confection-a98f9d6/tests/test_issue61.py000066400000000000000000000020661516055431200223730ustar00rootroot00000000000000"""Test for issue #61: Top-level section references in interpolation. Users want to reference a top-level section (e.g. ${logger}) from within another section, instead of having to nest it (${logger.logger}). Currently this causes an InterpolationDepthError because configparser's ExtendedInterpolation only resolves ${section.key} not ${section}. """ from confection import Config def test_top_level_section_reference(): """Referencing a top-level section via ${section} should work.""" config_str = """ [logger] name = "test_logger" [training] batch_size = 32 logger = ${logger} """ # This currently raises InterpolationDepthError cfg = Config().from_str(config_str) assert cfg["training"]["logger"]["name"] == "test_logger" def test_nested_section_reference_works(): """The nested workaround ${section.key} should work as before.""" config_str = """ [logger] name = "test_logger" [training] batch_size = 32 logger_name = ${logger.name} """ cfg = Config().from_str(config_str) assert cfg["training"]["logger_name"] == "test_logger" explosion-confection-a98f9d6/tests/test_pydantic_shim.py000066400000000000000000000135721516055431200237330ustar00rootroot00000000000000"""Tests for backward compatibility with pydantic BaseModel schemas. These tests verify that downstream libraries (spaCy, thinc, etc.) can continue passing pydantic BaseModel subclasses to registry.resolve() and registry.fill() even though confection no longer depends on pydantic. """ import sys import pytest if sys.version_info >= (3, 14): pytest.skip( "pydantic v1 is not compatible with Python 3.14+", allow_module_level=True ) pydantic = pytest.importorskip("pydantic") try: from pydantic.v1 import ( BaseModel, Field, StrictFloat, StrictInt, StrictStr, validator, ) except ImportError: from pydantic import ( # type: ignore # noqa: F401 BaseModel, Field, StrictFloat, StrictInt, StrictStr, validator, ) from confection.validation import Schema, ValidationError, ensure_schema # noqa: E402 # --- ensure_schema conversion --- class SimpleSchema(BaseModel): name: StrictStr = Field(..., title="Name") value: StrictInt = Field(10, title="Value") class Config: extra = "forbid" class InnerSchema(BaseModel): x: StrictInt class Config: extra = "forbid" class OuterSchema(BaseModel): inner: InnerSchema label: StrictStr = "default" class Config: extra = "forbid" def test_converts_to_schema_subclass(): converted = ensure_schema(SimpleSchema) assert issubclass(converted, Schema) def test_extracts_fields(): converted = ensure_schema(SimpleSchema) assert "name" in converted.model_fields assert "value" in converted.model_fields assert converted.model_fields["name"].is_required() assert not converted.model_fields["value"].is_required() assert converted.model_fields["value"].default == 10 def test_extracts_config(): converted = ensure_schema(SimpleSchema) assert converted.model_config["extra"] == "forbid" def test_schema_passthrough(): class MySchema(Schema): x: int assert ensure_schema(MySchema) is MySchema def test_caching(): a = ensure_schema(SimpleSchema) b = ensure_schema(SimpleSchema) assert a is b def test_nested_conversion(): converted = ensure_schema(OuterSchema) inner_type = converted.model_fields["inner"].annotation assert issubclass(inner_type, Schema) assert "x" in inner_type.model_fields # --- Validation delegates to pydantic --- def test_validate_correct_data(): converted = ensure_schema(SimpleSchema) result = converted.model_validate({"name": "test", "value": 5}) assert result.name == "test" assert result.value == 5 def test_validate_fills_defaults(): converted = ensure_schema(SimpleSchema) result = converted.model_validate({"name": "test"}) assert result.value == 10 def test_strict_str_rejects_int(): converted = ensure_schema(SimpleSchema) with pytest.raises(ValidationError): converted.model_validate({"name": 123}) def test_extra_fields_rejected(): converted = ensure_schema(SimpleSchema) with pytest.raises(ValidationError): converted.model_validate({"name": "x", "extra": 1}) def test_pydantic_validator_works(): class ValidatedModel(BaseModel): name: StrictStr class Config: extra = "forbid" @validator("name") def name_must_be_upper(cls, v): if v != v.upper(): raise ValueError("must be uppercase") return v converted = ensure_schema(ValidatedModel) with pytest.raises(ValidationError): converted.model_validate({"name": "hello"}) converted.model_validate({"name": "HELLO"}) # --- Pydantic v2 native models --- import pydantic as _pydantic_v2 # noqa: E402 class V2SimpleSchema(_pydantic_v2.BaseModel): model_config = _pydantic_v2.ConfigDict(extra="forbid") name: str value: int = 10 class V2InnerSchema(_pydantic_v2.BaseModel): model_config = _pydantic_v2.ConfigDict(extra="forbid") x: int class V2OuterSchema(_pydantic_v2.BaseModel): model_config = _pydantic_v2.ConfigDict(extra="forbid") inner: V2InnerSchema label: str = "default" def test_v2_converts_to_schema(): converted = ensure_schema(V2SimpleSchema) assert issubclass(converted, Schema) def test_v2_extracts_fields(): converted = ensure_schema(V2SimpleSchema) assert "name" in converted.model_fields assert "value" in converted.model_fields assert converted.model_fields["name"].is_required() assert converted.model_fields["value"].default == 10 def test_v2_extracts_config(): converted = ensure_schema(V2SimpleSchema) assert converted.model_config["extra"] == "forbid" def test_v2_nested_conversion(): converted = ensure_schema(V2OuterSchema) inner_type = converted.model_fields["inner"].annotation assert issubclass(inner_type, Schema) assert "x" in inner_type.model_fields def test_v2_validate_correct(): converted = ensure_schema(V2SimpleSchema) result = converted.model_validate({"name": "test"}) assert result.name == "test" assert result.value == 10 def test_v2_validate_rejects_extra(): converted = ensure_schema(V2SimpleSchema) with pytest.raises(ValidationError): converted.model_validate({"name": "x", "extra": 1}) # --- Config integration with pydantic schema --- def test_config_from_str_with_pydantic_schema(): """Config.from_str works with a pydantic schema for validation and defaults.""" from confection import Config class MyPydanticSchema(BaseModel): name: StrictStr value: StrictInt = 10 class Config: extra = "forbid" class TopSchema(BaseModel): section: MyPydanticSchema config = Config().from_str( """ [section] name = "test" """, interpolate=False, schema=TopSchema, ) assert config["section"]["name"] == "test" assert config["section"]["value"] == 10 explosion-confection-a98f9d6/tests/test_registry_fill.py000066400000000000000000000213661516055431200237560ustar00rootroot00000000000000"""Test registry.fill() default-filling from function signatures.""" import catalogue import pytest from confection import Config, registry from confection._errors import ConfigValidationError # --- Test registry setup --- class _test_registry(registry): optimizers = catalogue.create("test_fill", "optimizers", entry_points=False) schedules = catalogue.create("test_fill", "schedules", entry_points=False) models = catalogue.create("test_fill", "models", entry_points=False) @_test_registry.optimizers.register("Adam.v1") def adam(learn_rate: float = 0.001, beta1: float = 0.9, beta2: float = 0.999): return {"learn_rate": learn_rate, "beta1": beta1, "beta2": beta2} @_test_registry.optimizers.register("SGD.v1") def sgd(learn_rate: float = 0.01, momentum: float = 0.9): return {"learn_rate": learn_rate, "momentum": momentum} @_test_registry.schedules.register("linear.v1") def linear(start: float = 0.0, end: float = 1.0, steps: int = 100): return [start + (end - start) * i / steps for i in range(steps)] @_test_registry.models.register("cnn.v1") def cnn(width: int = 128, depth: int = 3, dropout: float = 0.1): return {"width": width, "depth": depth, "dropout": dropout} @_test_registry.models.register("no_defaults.v1") def no_defaults(width: int, depth: int): return {"width": width, "depth": depth} # --- Tests --- def test_fill_basic(): """Fill adds missing defaults from the registered function.""" config = Config().from_str( """ [optimizer] @optimizers = "Adam.v1" learn_rate = 0.01 """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["optimizer"]["learn_rate"] == 0.01 assert filled["optimizer"]["beta1"] == 0.9 assert filled["optimizer"]["beta2"] == 0.999 def test_fill_preserves_provided(): """Values explicitly provided should not be overwritten.""" config = Config().from_str( """ [optimizer] @optimizers = "Adam.v1" learn_rate = 0.05 beta1 = 0.8 beta2 = 0.99 """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["optimizer"]["learn_rate"] == 0.05 assert filled["optimizer"]["beta1"] == 0.8 assert filled["optimizer"]["beta2"] == 0.99 def test_fill_all_defaults(): """When no args are provided, all defaults should be filled.""" config = Config().from_str( """ [optimizer] @optimizers = "Adam.v1" """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["optimizer"]["learn_rate"] == 0.001 assert filled["optimizer"]["beta1"] == 0.9 assert filled["optimizer"]["beta2"] == 0.999 def test_fill_no_defaults(): """Function with no defaults should not add anything.""" config = Config().from_str( """ [model] @models = "no_defaults.v1" width = 64 depth = 2 """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["model"]["width"] == 64 assert filled["model"]["depth"] == 2 def test_fill_nested_promise(): """Defaults are filled recursively into nested promises.""" config = Config().from_str( """ [optimizer] @optimizers = "SGD.v1" [optimizer.learn_rate] @schedules = "linear.v1" end = 0.5 """, interpolate=False, ) filled = _test_registry.fill(config) # Outer promise filled assert filled["optimizer"]["momentum"] == 0.9 # Inner promise filled lr = filled["optimizer"]["learn_rate"] assert lr["start"] == 0.0 assert lr["end"] == 0.5 # provided, not overwritten assert lr["steps"] == 100 def test_fill_non_promise_sections(): """Non-promise sections are passed through unchanged.""" config = Config().from_str( """ [training] epochs = 10 [optimizer] @optimizers = "Adam.v1" """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["training"]["epochs"] == 10 assert filled["optimizer"]["beta1"] == 0.9 def test_fill_preserves_registry_key(): """The @registry key should be preserved in the filled config.""" config = Config().from_str( """ [optimizer] @optimizers = "Adam.v1" """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["optimizer"]["@optimizers"] == "Adam.v1" def test_fill_returns_config(): """fill() should return a Config object.""" config = Config().from_str( """ [optimizer] @optimizers = "Adam.v1" """, interpolate=False, ) filled = _test_registry.fill(config) assert isinstance(filled, Config) def test_fill_top_level_promise_raises(): """Top-level config can't be a promise.""" with pytest.raises(ConfigValidationError): _test_registry.fill({"@optimizers": "Adam.v1"}) def test_fill_with_overrides(): """Overrides should be applied before filling defaults.""" config = Config().from_str( """ [optimizer] @optimizers = "Adam.v1" learn_rate = 0.01 """, interpolate=False, ) filled = _test_registry.fill( config, overrides={"optimizer.learn_rate": 0.1}, interpolate=True ) assert filled["optimizer"]["learn_rate"] == 0.1 assert filled["optimizer"]["beta1"] == 0.9 def test_fill_promise_rejects_extra_args(): """Extra args not in the function signature should be rejected.""" config = Config( { "optimizer": { "@optimizers": "Adam.v1", "learn_rate": 0.01, "metadata": {"nested_model": {"@models": "cnn.v1"}}, } } ) with pytest.raises(ConfigValidationError): _test_registry.fill(config) def test_fill_nested_non_promise_dict(): """Non-promise dicts nested under a section with promises should recurse.""" config = Config().from_str( """ [section] [section.sub] x = 1 [section.model] @models = "cnn.v1" width = 64 """, interpolate=False, ) filled = _test_registry.fill(config) assert filled["section"]["sub"] == {"x": 1} assert filled["section"]["model"]["depth"] == 3 def test_fill_skips_unknown_functions(): """fill() should skip promises referencing unknown functions, not crash.""" config = Config( { "section": { "known": {"@optimizers": "Adam.v1"}, "unknown": {"@optimizers": "nonexistent.v1"}, } } ) filled = _test_registry.fill(config) # Known function gets defaults filled assert filled["section"]["known"]["beta1"] == 0.9 # Unknown function passes through unchanged assert filled["section"]["unknown"] == {"@optimizers": "nonexistent.v1"} def test_fill_strips_extra_fields_with_schema(): """fill() with a schema that has extra='forbid' should strip unknown keys.""" from confection.validation import Schema class TrainingSchema(Schema): model_config = {"extra": "forbid"} patience: int = 10 dropout: float = 0.2 class MySchema(Schema): model_config = {"extra": "forbid"} training: TrainingSchema config = Config({"training": {"patience": 5, "extra_field": "hello"}}) filled = _test_registry.fill(config, schema=MySchema) assert filled["training"]["patience"] == 5 assert filled["training"]["dropout"] == 0.2 assert "extra_field" not in filled["training"] def test_fill_positional_args(): """Functions with *args should have positional args filled under '*'.""" @_test_registry.models.register("chain.v1") def chain(*layers): return list(layers) config = Config( { "model": { "@models": "chain.v1", "*": { "layer1": {"@models": "cnn.v1", "width": 64}, "layer2": {"@models": "cnn.v1"}, }, } } ) filled = _test_registry.fill(config) # Positional args should pass through, nested promise defaults filled assert filled["model"]["*"]["layer2"]["depth"] == 3 assert filled["model"]["*"]["layer1"]["width"] == 64 def test_fill_positional_args_not_flagged_as_unexpected(): """The '*' key should not be flagged as an unexpected argument.""" config = Config( { "model": { "@models": "chain.v1", "*": {"a": {"@models": "cnn.v1"}}, } } ) # Should not raise ConfigValidationError about unexpected '*' filled = _test_registry.fill(config) assert "*" in filled["model"] def test_fill_with_interpolation(): """fill() with interpolate=True should resolve variables.""" config = Config().from_str( """ [hyper] lr = 0.01 [optimizer] @optimizers = "Adam.v1" learn_rate = ${hyper.lr} """, interpolate=False, ) filled = _test_registry.fill(config, interpolate=True) assert filled["optimizer"]["learn_rate"] == 0.01 assert filled["optimizer"]["beta1"] == 0.9 explosion-confection-a98f9d6/tests/test_registry_resolve.py000066400000000000000000000217411516055431200245040ustar00rootroot00000000000000"""Test registry.resolve() and the Promise lifecycle.""" from typing import Callable, List import catalogue import pytest from confection import Config, registry from confection._constants import ARGS_FIELD_ALIAS, RESERVED_FIELDS_REVERSE from confection._errors import ConfigValidationError from confection._registry import ( Promise, _deep_copy_with_uncopyable, _is_config_section, alias_generator, fix_positionals, insert_promises, resolve_promises, ) # --- Test registry setup --- class _test_registry(registry): namespace = "test_resolve" cats = catalogue.create(namespace, "cats", entry_points=False) optimizers = catalogue.create(namespace, "optimizers", entry_points=False) schedules = catalogue.create(namespace, "schedules", entry_points=False) layers = catalogue.create(namespace, "layers", entry_points=False) @_test_registry.cats.register("catsie.v1") def catsie(evil: bool, cute: bool = True) -> str: if evil: return "scratch!" return "meow" @_test_registry.optimizers.register("Adam.v1") def adam(learn_rate: float = 0.001, beta1: float = 0.9, beta2: float = 0.999): return {"learn_rate": learn_rate, "beta1": beta1, "beta2": beta2} @_test_registry.schedules.register("decay.v1") def decay(base_rate: float, repeat: int) -> List[float]: return [base_rate] * repeat @_test_registry.optimizers.register("cool.v1") def cool_optimizer(learn_rate, beta1: float = 0.9): return {"learn_rate": learn_rate, "beta1": beta1} @_test_registry.layers.register("linear.v1") def linear_layer(width: int, init: Callable = lambda: None): return {"width": width, "init": init} # --- resolve basic --- def test_resolve_simple(): config = Config({"cat": {"@cats": "catsie.v1", "evil": True}}) result = _test_registry.resolve(config) assert result["cat"] == "scratch!" def test_resolve_with_defaults(): config = Config({"cat": {"@cats": "catsie.v1", "evil": False}}) result = _test_registry.resolve(config) assert result["cat"] == "meow" def test_resolve_nested_promise(): """A promise arg can be another promise.""" config = Config( { "optimizer": { "@optimizers": "cool.v1", "learn_rate": { "@schedules": "decay.v1", "base_rate": 0.001, "repeat": 4, }, } } ) result = _test_registry.resolve(config) assert result["optimizer"]["learn_rate"] == [0.001] * 4 assert result["optimizer"]["beta1"] == 0.9 def test_resolve_non_promise_passthrough(): config = Config( {"training": {"epochs": 10}, "cat": {"@cats": "catsie.v1", "evil": True}} ) result = _test_registry.resolve(config) assert result["training"] == {"epochs": 10} assert result["cat"] == "scratch!" def test_resolve_from_str(): config = Config().from_str(""" [optimizer] @optimizers = "Adam.v1" learn_rate = 0.01 """) result = _test_registry.resolve(config) assert result["optimizer"]["learn_rate"] == 0.01 assert result["optimizer"]["beta1"] == 0.9 # --- Promise class --- def test_promise_from_dict(): values = {"@cats": "catsie.v1", "evil": True, "cute": False} promise = Promise.from_dict(_test_registry, values) assert promise.registry == "cats" assert promise.name == "catsie.v1" assert promise.kwargs == {"evil": True, "cute": False} def test_promise_resolve(): values = {"@cats": "catsie.v1", "evil": True} promise = Promise.from_dict(_test_registry, values) assert promise.resolve() == "scratch!" def test_promise_return_type(): values = {"@cats": "catsie.v1", "evil": True} promise = Promise.from_dict(_test_registry, values) assert promise.return_type is str # --- insert_promises --- def test_insert_promises(): config = {"cat": {"@cats": "catsie.v1", "evil": True}, "x": 1} result = insert_promises(_test_registry, config, resolve=True) assert isinstance(result["cat"], Promise) assert result["x"] == 1 def test_insert_promises_nested(): config = { "section": { "cat": {"@cats": "catsie.v1", "evil": False}, "val": 42, } } result = insert_promises(_test_registry, config, resolve=True) assert isinstance(result["section"]["cat"], Promise) assert result["section"]["val"] == 42 # --- resolve_promises --- def test_resolve_promises(): config = {"cat": {"@cats": "catsie.v1", "evil": True}, "x": 1} promised = insert_promises(_test_registry, config, resolve=True) resolved = resolve_promises(promised) assert resolved["cat"] == "scratch!" assert resolved["x"] == 1 # --- fix_positionals --- def test_fix_positionals_dict_to_tuple(): config = {"*": {"0": "a", "1": "b"}, "x": 1} result = fix_positionals(config) assert result["*"] == ("a", "b") assert result["x"] == 1 def test_fix_positionals_nested(): config = {"section": {"*": {"0": "a"}, "val": 1}} result = fix_positionals(config) assert result["section"]["*"] == ("a",) def test_fix_positionals_list(): config = [{"*": {"0": 1}}, {"x": 2}] result = fix_positionals(config) assert result[0]["*"] == (1,) assert result[1] == {"x": 2} def test_fix_positionals_tuple(): config = ({"*": {"0": 1}},) result = fix_positionals(config) assert result[0]["*"] == (1,) assert isinstance(result, tuple) def test_fix_positionals_scalar(): assert fix_positionals(42) == 42 assert fix_positionals("hello") == "hello" # --- _is_config_section --- def test_is_config_section(): assert _is_config_section({"a": 1, "b": 2}) assert not _is_config_section([1, 2]) assert not _is_config_section("hello") assert _is_config_section({}) # --- alias_generator --- def test_alias_generator_args_field(): assert alias_generator(ARGS_FIELD_ALIAS) == "*" def test_alias_generator_reserved(): for alias, original in RESERVED_FIELDS_REVERSE.items(): assert alias_generator(alias) == original def test_alias_generator_normal(): assert alias_generator("some_field") == "some_field" # --- _deep_copy_with_uncopyable --- def test_deep_copy_dict(): original = {"a": [1, 2], "b": {"c": 3}} copied = _deep_copy_with_uncopyable(original) assert copied == original copied["a"].append(3) assert original["a"] == [1, 2] def test_deep_copy_list(): original = [{"a": 1}, {"b": 2}] copied = _deep_copy_with_uncopyable(original) assert copied == original copied[0]["a"] = 99 assert original[0]["a"] == 1 def test_deep_copy_tuple(): original = ({"a": 1},) copied = _deep_copy_with_uncopyable(original) assert copied == original assert isinstance(copied, tuple) def test_deep_copy_scalar(): assert _deep_copy_with_uncopyable(42) == 42 assert _deep_copy_with_uncopyable("hello") == "hello" def test_deep_copy_generator(): """Generators can't be deepcopied — should pass through.""" def gen(): yield 1 g = gen() copied = _deep_copy_with_uncopyable(g) assert copied is g # same object, not copied def test_deep_copy_memo(): """Shared references should be preserved.""" shared = [1, 2, 3] original = {"a": shared, "b": shared} copied = _deep_copy_with_uncopyable(original) assert copied["a"] is copied["b"] # still shared assert copied["a"] is not original["a"] # but different from original # --- registry.has / registry.get --- def test_registry_has(): assert _test_registry.has("cats", "catsie.v1") assert not _test_registry.has("cats", "nonexistent") assert not _test_registry.has("nonexistent_registry", "anything") def test_registry_get(): func = _test_registry.get("cats", "catsie.v1") assert func is catsie def test_registry_get_unknown_registry(): with pytest.raises(ValueError, match="Unknown registry"): _test_registry.get("nonexistent", "anything") def test_registry_get_unknown_func(): with pytest.raises((ValueError, catalogue.RegistryError)): _test_registry.get("cats", "nonexistent") # --- get_constructor / parse_args --- def test_get_constructor(): assert _test_registry.get_constructor({"@cats": "catsie.v1", "evil": True}) == ( "cats", "catsie.v1", ) def test_get_constructor_multiple_refs(): with pytest.raises(ConfigValidationError): _test_registry.get_constructor({"@cats": "catsie.v1", "@dogs": "doggo.v1"}) def test_parse_args(): args, kwargs = _test_registry.parse_args( {"@cats": "catsie.v1", "evil": True, "cute": False} ) assert args == [] assert kwargs == {"evil": True, "cute": False} def test_parse_args_with_positionals(): args, kwargs = _test_registry.parse_args( {"@cats": "catsie.v1", "*": [1, 2], "evil": True} ) assert args == [1, 2] assert kwargs == {"evil": True} # --- resolve top-level promise raises --- def test_resolve_top_level_promise(): with pytest.raises(ConfigValidationError): _test_registry.resolve({"@cats": "catsie.v1", "evil": True}) explosion-confection-a98f9d6/tests/test_schema.py000066400000000000000000000076701516055431200223420ustar00rootroot00000000000000"""Test schema validation and default filling at the Config layer.""" from typing import List, Optional import pytest from confection import Config from confection._errors import ConfigValidationError from confection.validation import Schema class TrainingSchema(Schema): patience: int dropout: float = 0.2 use_vectors: bool = False class NlpSchema(Schema): lang: str pipeline: List[str] = [] class FullSchema(Schema): training: TrainingSchema nlp: NlpSchema # -- fill_defaults -- def test_fill_defaults_top_level(): config = Config({"training": {"patience": 10}}) config.fill_defaults(FullSchema) assert config["training"]["dropout"] == 0.2 assert config["training"]["use_vectors"] is False def test_fill_defaults_preserves_existing(): config = Config({"training": {"patience": 10, "dropout": 0.5}}) config.fill_defaults(FullSchema) assert config["training"]["dropout"] == 0.5 def test_fill_defaults_adds_missing_sections(): config = Config({"training": {"patience": 10}}) config.fill_defaults(FullSchema) # nlp has all defaults so it should be filled assert "nlp" not in config # nlp section itself is required, not defaulted def test_fill_defaults_returns_self(): config = Config({"training": {"patience": 10}}) result = config.fill_defaults(FullSchema) assert result is config # -- validate -- def test_validate_passes(): config = Config( {"training": {"patience": 10, "dropout": 0.2}, "nlp": {"lang": "en"}} ) config.validate(FullSchema) # should not raise def test_validate_missing_required(): config = Config({"training": {"dropout": 0.5}, "nlp": {"lang": "en"}}) with pytest.raises(ConfigValidationError): config.validate(FullSchema) def test_validate_wrong_type(): config = Config( {"training": {"patience": "nope", "dropout": 0.2}, "nlp": {"lang": "en"}} ) with pytest.raises(ConfigValidationError): config.validate(FullSchema) # -- from_str with schema -- def test_from_str_with_schema(): config = Config().from_str( """ [training] patience = 10 [nlp] lang = "en" """, interpolate=False, schema=FullSchema, ) assert config["training"]["patience"] == 10 assert config["training"]["dropout"] == 0.2 assert config["training"]["use_vectors"] is False def test_from_str_schema_validates(): with pytest.raises(ConfigValidationError): Config().from_str( """ [training] dropout = 0.5 [nlp] lang = "en" """, interpolate=False, schema=FullSchema, ) def test_from_str_schema_with_interpolation(): config = Config().from_str( """ [training] patience = 10 [nlp] lang = "en" """, interpolate=True, schema=FullSchema, ) assert config["training"]["dropout"] == 0.2 # -- Schema with extra="forbid" -- class StrictSchema(Schema): model_config = {"extra": "forbid"} x: int y: str = "default" def test_validate_extra_forbidden(): config = Config({"x": 1, "y": "hello", "z": "extra"}) with pytest.raises(ConfigValidationError): config.validate(StrictSchema) # -- Schema with Optional fields -- class OptionalSchema(Schema): name: str description: Optional[str] = None def test_optional_field_defaults_to_none(): config = Config({"name": "test"}) config.fill_defaults(OptionalSchema) assert config["description"] is None def test_optional_field_accepts_value(): config = Config({"name": "test", "description": "hello"}) config.validate(OptionalSchema) # should not raise # -- Flat schema (no nesting) -- class FlatSchema(Schema): x: int y: float = 3.14 z: str = "hello" def test_flat_schema_from_str(): """Schema works for flat configs too (top-level keys are leaves, not sections).""" config = Config({"x": 1}) config.fill_defaults(FlatSchema) assert config["y"] == 3.14 assert config["z"] == "hello" explosion-confection-a98f9d6/tests/test_typechecker_edges.py000066400000000000000000000146451516055431200245570ustar00rootroot00000000000000"""Tests for typechecker edge cases.""" from dataclasses import dataclass from enum import Enum from typing import Annotated, Any from confection.typechecker import check_type from confection.validation import Field, Schema # --- custom_handlers --- def test_custom_handler(): def handle_str(value, annotation, handlers, ctx): return value == "magic" assert check_type("magic", int, custom_handlers={str: handle_str}) assert not check_type("other", int, custom_handlers={str: handle_str}) # --- Annotated with Strict() --- try: from pydantic import Strict except ImportError: class Strict: # type: ignore strict = True def test_strict_int(): assert check_type(42, Annotated[int, Strict()]) assert not check_type(True, Annotated[int, Strict()]) assert not check_type("42", Annotated[int, Strict()]) def test_strict_float(): assert check_type(3.14, Annotated[float, Strict()]) assert not check_type(42, Annotated[float, Strict()]) def test_strict_str(): assert check_type("hi", Annotated[str, Strict()]) assert not check_type(42, Annotated[str, Strict()]) def test_strict_bool(): assert check_type(True, Annotated[bool, Strict()]) assert not check_type(1, Annotated[bool, Strict()]) def test_strict_other(): assert check_type([1], Annotated[list, Strict()]) # --- String forward reference --- def test_string_annotation_accepts_anything(): assert check_type(42, "SomeForwardRef") assert check_type("hi", "SomeForwardRef") # --- String enum --- class Color(str, Enum): RED = "red" GREEN = "green" def test_string_enum(): assert check_type("red", Color) assert not check_type("blue", Color) # --- float with non-numeric --- def test_float_rejects_list(): assert not check_type([], float) # --- Type[X] with non-class generic arg --- def test_type_non_class_arg(): """Type[X] where X causes TypeError in issubclass should accept.""" from typing import Type # This would raise TypeError in issubclass assert check_type(int, Type[Any]) # --- Dataclass with dict --- @dataclass class Point: x: int y: int def test_dataclass_accepts_dict(): assert check_type({"x": 1, "y": 2}, Point) def test_dataclass_dict_validates_fields(): assert not check_type({"x": "bad", "y": 2}, Point) def test_dataclass_with_dataclass_value(): p = Point(x=1, y=2) assert check_type(p, Point) def test_dataclass_value_validates_fields(): p = Point(x=1, y=2) # Manually set bad type to test field validation p.x = "bad" # type: ignore assert not check_type(p, Point) # --- Schema with aliased field in decompose --- class AliasedSchema(Schema): x: int = Field(default=..., alias="x_alias") def test_schema_decompose_alias(): assert check_type({"x_alias": 42}, AliasedSchema) assert not check_type({"x_alias": "bad"}, AliasedSchema) # --- Fall-through return False --- def test_unknown_annotation(): """An annotation that doesn't match any branch returns False.""" # A module object isn't a type, not a TypeVar, not a string, etc. import os assert not check_type(42, os) # --- Pydantic v1 validator edges --- def test_pydantic_v1_validator_many_params(): """Validators with >2 params are skipped.""" class MyType: @classmethod def __get_validators__(cls): def three_params(v, field, config): return v yield three_params # Should still pass (validator is skipped) assert check_type(42, MyType) def test_pydantic_v1_validator_sig_error(): """Validators with un-inspectable signatures default to 1 param.""" class MyType: @classmethod def __get_validators__(cls): yield len # len's signature can be inspected, but let's test the path # len(42) will raise TypeError assert not check_type(42, MyType) # --- Pydantic v2 match edges --- def test_pydantic_v2_no_function_key(): """Schema without 'function' key in result.""" class MyType: @classmethod def __get_pydantic_core_schema__(cls, source, handler): return {"type": "any"} # no function key assert not check_type(42, MyType) def test_pydantic_v2_isinstance_shortcut(): """If value is already an instance, skip validator.""" class MyType: @classmethod def __get_pydantic_core_schema__(cls, source, handler): return {"type": "any"} assert check_type(MyType(), MyType) # --- Parameter.empty --- def test_parameter_empty(): import inspect assert check_type(42, inspect.Parameter.empty) assert check_type("anything", inspect.Parameter.empty) # --- Type[Union[...]] --- def test_type_union_arg(): """Type[Union[int, str]] — issubclass raises TypeError, should accept.""" from typing import Type, Union assert check_type(int, Type[Union[int, str]]) # --- _pydantic_v1_match isinstance shortcut --- def test_pydantic_v1_isinstance_shortcut(): class MyType: @classmethod def __get_validators__(cls): yield lambda v: v assert check_type(MyType(), MyType) # --- _pydantic_v1_match uninspectable signature --- def test_pydantic_v1_uninspectable_sig(): """Validator with uninspectable signature defaults to 1 param.""" class MyType: @classmethod def __get_validators__(cls): # A builtin with no inspectable sig in some Python versions class Uninspectable: def __call__(self, v): if not isinstance(v, int): raise ValueError return v # Make signature() raise __signature__ = property(lambda self: (_ for _ in ()).throw(ValueError)) yield Uninspectable() assert check_type(42, MyType) assert not check_type("hi", MyType) # --- TypeVar bound that fails isinstance --- def test_typevar_bound_isinstance_typeerror(): """TypeVar bound with ForwardRef that fails isinstance falls back to outer_match.""" from typing import ForwardRef, TypeVar T = TypeVar("T", bound=ForwardRef("NonExistent")) # ForwardRef can't be used with isinstance — falls back to outer_match # which accepts ForwardRefs assert check_type(42, T) # --- ctx default --- def test_check_type_creates_default_ctx(): """check_type works without explicit ctx.""" assert check_type(42, int) assert not check_type("hi", int) explosion-confection-a98f9d6/tests/test_util.py000066400000000000000000000060071516055431200220500ustar00rootroot00000000000000"""Test util.py: frozen collections, partial, try_dump_json.""" import copy import pytest from confection._errors import ConfigValidationError from confection.util import ( SimpleFrozenDict, SimpleFrozenList, partial, try_dump_json, ) # --- SimpleFrozenDict --- def test_frozen_dict_init(): d = SimpleFrozenDict({"a": 1, "b": 2}) assert d["a"] == 1 def test_frozen_dict_setitem(): d = SimpleFrozenDict() with pytest.raises(NotImplementedError): d["x"] = 1 def test_frozen_dict_pop(): d = SimpleFrozenDict({"a": 1}) with pytest.raises(NotImplementedError): d.pop("a") def test_frozen_dict_update(): d = SimpleFrozenDict() with pytest.raises(NotImplementedError): d.update({"x": 1}) def test_frozen_dict_deepcopy(): d = SimpleFrozenDict({"a": [1, 2]}) d2 = copy.deepcopy(d) assert isinstance(d2, SimpleFrozenDict) assert d2["a"] == [1, 2] assert d2["a"] is not d["a"] def test_frozen_dict_custom_error(): d = SimpleFrozenDict(error="custom error") with pytest.raises(NotImplementedError, match="custom error"): d["x"] = 1 # --- SimpleFrozenList --- def test_frozen_list_init(): lst = SimpleFrozenList([1, 2, 3]) assert lst[0] == 1 def test_frozen_list_append(): lst = SimpleFrozenList() with pytest.raises(NotImplementedError): lst.append(1) def test_frozen_list_clear(): lst = SimpleFrozenList([1]) with pytest.raises(NotImplementedError): lst.clear() def test_frozen_list_extend(): lst = SimpleFrozenList() with pytest.raises(NotImplementedError): lst.extend([1]) def test_frozen_list_insert(): lst = SimpleFrozenList() with pytest.raises(NotImplementedError): lst.insert(0, 1) def test_frozen_list_pop(): lst = SimpleFrozenList([1]) with pytest.raises(NotImplementedError): lst.pop() def test_frozen_list_remove(): lst = SimpleFrozenList([1]) with pytest.raises(NotImplementedError): lst.remove(1) def test_frozen_list_reverse(): lst = SimpleFrozenList([1, 2]) with pytest.raises(NotImplementedError): lst.reverse() def test_frozen_list_sort(): lst = SimpleFrozenList([2, 1]) with pytest.raises(NotImplementedError): lst.sort() def test_frozen_list_deepcopy(): lst = SimpleFrozenList([[1, 2], [3]]) lst2 = copy.deepcopy(lst) assert isinstance(lst2, SimpleFrozenList) assert lst2 == [[1, 2], [3]] assert lst2[0] is not lst[0] def test_frozen_list_custom_error(): lst = SimpleFrozenList(error="nope") with pytest.raises(NotImplementedError, match="nope"): lst.append(1) # --- partial --- def test_partial_basic(): def add(a, b): """Add two numbers.""" return a + b p = partial(add, 1) assert p(2) == 3 assert p.__doc__ == "Add two numbers." # --- try_dump_json error --- def test_try_dump_json_unserializable(): with pytest.raises(ConfigValidationError, match="Couldn't serialize"): try_dump_json(object()) explosion-confection-a98f9d6/tests/test_validate_type.py000066400000000000000000000276141516055431200237340ustar00rootroot00000000000000"""Tests for validate_type covering all type branches.""" from pathlib import Path, PurePath from typing import ( Any, Callable, Dict, FrozenSet, Iterable, Iterator, List, Literal, Mapping, NewType, Optional, Sequence, Set, Tuple, Type, TypeVar, Union, ) import pytest from confection.validation import ( Field, FieldInfo, Schema, _validate_schema, create_schema, ensure_schema, validate_type, ) # === None === def test_none(): assert validate_type(None, type(None)) is None assert validate_type(42, type(None)) is not None # === Annotated === try: from typing import Annotated except ImportError: Annotated = None # type: ignore @pytest.mark.skipif(Annotated is None, reason="Annotated not available") def test_annotated(): assert validate_type(42, Annotated[int, "metadata"]) is None assert validate_type("hi", Annotated[int, "metadata"]) is not None # === Union / Optional === def test_union(): assert validate_type(42, Union[int, str]) is None assert validate_type("hi", Union[int, str]) is None assert validate_type(3.14, Union[int, str]) is not None def test_optional(): assert validate_type(None, Optional[int]) is None assert validate_type(42, Optional[int]) is None assert validate_type("hi", Optional[int]) is not None def test_union_pipe_syntax(): assert validate_type(42, int | str) is None assert validate_type("hi", int | str) is None assert validate_type(3.14, int | str) is not None # === Literal === def test_literal(): assert validate_type("a", Literal["a", "b"]) is None assert validate_type("b", Literal["a", "b"]) is None assert validate_type("c", Literal["a", "b"]) is not None assert validate_type(1, Literal[1, 2]) is None assert validate_type(3, Literal[1, 2]) is not None # === NewType === def test_newtype(): UserId = NewType("UserId", int) assert validate_type(42, UserId) is None assert validate_type("hi", UserId) is not None # === TypeVar === def test_typevar_unbound(): T = TypeVar("T") assert validate_type("anything", T) is None def test_typevar_bound(): T = TypeVar("T", bound=int) assert validate_type(42, T) is None assert validate_type("hi", T) is not None def test_typevar_bound_union(): T = TypeVar("T", bound=Union[str, int]) assert validate_type("hello", T) is None assert validate_type(42, T) is None assert validate_type(3.14, T) is not None def test_typevar_bound_union_negative(): T = TypeVar("T", bound=Union[None, float]) assert validate_type(None, T) is None assert validate_type(3.14, T) is None assert validate_type("hello", T) is not None def test_typevar_constraints(): T = TypeVar("T", int, str) assert validate_type(42, T) is None assert validate_type("hi", T) is None assert validate_type(3.14, T) is not None # === Plain types === def test_bool(): assert validate_type(True, bool) is None assert validate_type(1, bool) is not None def test_int(): assert validate_type(42, int) is None assert validate_type(True, int) is not None # bool is not accepted as int assert validate_type("123", int) is None # string coercion assert validate_type("abc", int) is not None def test_float(): assert validate_type(3.14, float) is None assert validate_type(42, float) is None # int accepted for float assert validate_type(True, float) is not None assert validate_type("3.14", float) is None # string coercion assert validate_type("abc", float) is not None def test_str(): assert validate_type("hello", str) is None assert validate_type(42, str) is not None def test_path(): assert validate_type(Path("/tmp"), Path) is None assert validate_type("/tmp/foo", Path) is None # string coercion assert validate_type(PurePath("/tmp"), PurePath) is None assert validate_type("/tmp", PurePath) is None assert validate_type(42, Path) is not None # === Callable === def test_callable(): assert validate_type(lambda: None, Callable) is None assert validate_type(len, Callable) is None assert validate_type(42, Callable) is not None # === List === def test_list(): assert validate_type([1, 2, 3], list) is None assert validate_type([1, 2, 3], List[int]) is None assert validate_type([1, "a"], List[int]) is not None assert validate_type("not a list", list) is not None # === Dict === def test_dict(): assert validate_type({"a": 1}, dict) is None assert validate_type({"a": 1}, Dict[str, int]) is None assert validate_type({"a": "b"}, Dict[str, int]) is not None assert validate_type({1: "a"}, Dict[str, int]) is not None # === Tuple === def test_tuple_bare(): assert validate_type((1, 2), tuple) is None assert validate_type("hi", tuple) is not None def test_tuple_fixed(): assert validate_type((1, "a"), Tuple[int, str]) is None assert validate_type((1, 2), Tuple[int, str]) is not None assert validate_type((1,), Tuple[int, str]) is not None # wrong length assert validate_type((1, "a", 3), Tuple[int, str]) is not None def test_tuple_variable(): assert validate_type((1, 2, 3), Tuple[int, ...]) is None assert validate_type((), Tuple[int, ...]) is None assert validate_type((1, "a"), Tuple[int, ...]) is not None # === Set / FrozenSet === def test_set(): assert validate_type({1, 2}, set) is None assert validate_type({1, 2}, Set[int]) is None assert validate_type({1, "a"}, Set[int]) is not None assert validate_type([1, 2], set) is not None def test_frozenset(): assert validate_type(frozenset([1, 2]), frozenset) is None assert validate_type(frozenset([1, 2]), FrozenSet[int]) is None assert validate_type(frozenset([1, "a"]), FrozenSet[int]) is not None assert validate_type({1, 2}, FrozenSet[int]) is not None # === Sequence === def test_sequence(): assert validate_type([1, 2], Sequence[int]) is None assert validate_type((1, 2), Sequence[int]) is None assert validate_type("hello", Sequence) is None # str is a Sequence assert validate_type([1, "a"], Sequence[int]) is not None assert validate_type(42, Sequence) is not None # === Iterable === def test_iterable(): assert validate_type([1, 2], Iterable) is None assert validate_type("hi", Iterable) is None assert validate_type(42, Iterable) is not None # === Mapping === def test_mapping(): assert validate_type({"a": 1}, Mapping[str, int]) is None assert validate_type(42, Mapping) is not None # === Iterator === def test_iterator(): assert validate_type(iter([1, 2]), Iterator) is None assert validate_type(42, Iterator) is not None # === Type[X] === def test_type(): assert validate_type(int, Type[int]) is None assert validate_type(bool, Type[int]) is None # subclass assert validate_type(str, Type[int]) is not None assert validate_type(42, Type[int]) is not None assert validate_type(int, Type[Any]) is None # === Schema-as-dict validation === def test_schema_as_dict(): MySchema = create_schema( "MySchema", __config__={"extra": "forbid"}, x=(int, Field(...)), y=(str, Field("default")), ) assert validate_type({"x": 1}, MySchema) is None assert validate_type({"x": 1, "y": "hi"}, MySchema) is None assert validate_type(42, MySchema) is not None # not a dict # === Pydantic hooks === def test_pydantic_core_schema_hook(): """Types with __get_pydantic_core_schema__ get their validator called.""" class MyType: @classmethod def __get_pydantic_core_schema__(cls, source_type, handler): def validate(v): if not isinstance(v, int): raise ValueError("expected int") return v return { "type": "function-plain", "function": {"type": "no-info", "function": validate}, } assert validate_type(42, MyType) is None assert validate_type("hi", MyType) is not None def test_pydantic_v1_validators_hook(): """Types with __get_validators__ get their validators called.""" class MyType: @classmethod def __get_validators__(cls): def check_positive(v): if not isinstance(v, int) or v <= 0: raise ValueError("must be positive int") return v yield check_positive assert validate_type(5, MyType) is None assert validate_type(-1, MyType) is not None assert validate_type("hi", MyType) is not None # === Generator passthrough === def test_generator_passthrough(): def gen(): yield 1 g = gen() assert validate_type(g, int) is None # generators always pass assert next(g) == 1 # not consumed # === _validate_schema === def test_validate_schema_extra_forbid(): fields = {"x": FieldInfo(default=...)} fields["x"].annotation = int config = {"extra": "forbid"} errors = _validate_schema({"x": 1, "extra_key": 2}, fields, config, None) assert any("Extra inputs" in e["msg"] for e in errors) def test_validate_schema_extra_allow(): fields = {"x": FieldInfo(default=...)} fields["x"].annotation = int config = {"extra": "allow"} errors = _validate_schema({"x": 1, "extra_key": 2}, fields, config, None) assert not errors def test_validate_schema_missing_required(): fields = {"x": FieldInfo(default=...)} fields["x"].annotation = int config = {"extra": "forbid"} errors = _validate_schema({}, fields, config, None) assert any("required" in e["msg"].lower() for e in errors) def test_validate_schema_alias(): f = FieldInfo(default=..., alias="x_alias") f.annotation = int fields = {"x": f} config = {"extra": "forbid"} errors = _validate_schema({"x_alias": 1}, fields, config, None) assert not errors def test_validate_schema_alias_generator(): f = FieldInfo(default=...) f.annotation = int fields = {"my_field": f} config = {"extra": "forbid"} errors = _validate_schema( {"MY_FIELD": 1}, fields, config, lambda name: name.upper() ) assert not errors # === ensure_schema === def test_ensure_schema_passthrough(): """Schema subclass passes through unchanged.""" class MySchema(Schema): model_config = {"extra": "forbid"} assert ensure_schema(MySchema) is MySchema def test_ensure_schema_from_our_schema(): """create_schema output passes through.""" s = create_schema("Test", __config__={"extra": "forbid"}, x=(int, Field(...))) assert ensure_schema(s) is s # === model_dump === def test_model_dump(): MySchema = create_schema( "Test", __config__={"extra": "forbid"}, x=(int, Field(...)), y=(str, Field("default")), ) instance = MySchema(x=1) assert instance.model_dump() == {"x": 1, "y": "default"} # === Schema.from_function === def test_from_function_basic(): def my_func(x: int, y: str = "hello"): pass schema = Schema.from_function(my_func) assert "x" in schema.model_fields assert "y" in schema.model_fields assert schema.model_fields["x"].annotation is int assert schema.model_fields["y"].default == "hello" def test_from_function_var_positional(): def my_func(*args: int): pass schema = Schema.from_function(my_func) assert "args" in schema.model_fields # Should be Sequence[int] ann = schema.model_fields["args"].annotation assert hasattr(ann, "__origin__") # is generic def test_from_function_var_keyword_skipped(): def my_func(x: int, **kwargs): pass schema = Schema.from_function(my_func) assert "x" in schema.model_fields assert "kwargs" not in schema.model_fields def test_from_function_no_annotations(): def my_func(x, y=10): pass schema = Schema.from_function(my_func) assert schema.model_fields["x"].annotation is Any assert schema.model_fields["y"].default == 10 explosion-confection-a98f9d6/tests/test_validation_edges.py000066400000000000000000000201141516055431200243670ustar00rootroot00000000000000"""Tests for edge cases in validation.py.""" import sys from typing import Optional import pytest from confection.validation import ( Field, FieldInfo, Schema, _is_pydantic_model, _pydantic_instance_to_dict, _validate_schema, create_schema, ensure_schema, validate_type, ) # --- Schema with FieldInfo as class default --- class SchemaWithFieldInfo(Schema): x: int = Field(42) y: str = Field("hello") def test_field_info_as_class_default(): assert SchemaWithFieldInfo.model_fields["x"].default == 42 assert SchemaWithFieldInfo.model_fields["y"].default == "hello" def test_field_info_required(): class RequiredSchema(Schema): x: int = Field(...) assert RequiredSchema.model_fields["x"].is_required() # --- Schema field aliases --- class AliasedSchema(Schema): model_config = {"extra": "forbid"} x: int = Field(default=..., alias="x_alias") def test_model_validate_with_alias(): result = AliasedSchema.model_validate({"x_alias": 42}) assert result.x_alias == 42 def test_model_validate_alias_fills_default(): class AliasDefault(Schema): x: int = Field(default=10, alias="x_alias") result = AliasDefault.model_validate({}) assert result.x_alias == 10 # --- Schema with alias_generator --- class AliasGenSchema(Schema): model_config = {"extra": "forbid", "alias_generator": lambda name: name.upper()} my_field: int def test_model_validate_with_alias_generator(): result = AliasGenSchema.model_validate({"MY_FIELD": 42}) assert result.MY_FIELD == 42 def test_model_validate_alias_generator_fills_default(): class AliasGenDefault(Schema): model_config = {"alias_generator": lambda name: name.upper()} x: int = 99 result = AliasGenDefault.model_validate({}) assert result.X == 99 # --- model_dump with nested Schema --- class Inner(Schema): a: int class Outer(Schema): inner: Inner b: str = "hi" def test_model_dump_nested(): instance = Outer(inner=Inner(a=1)) dumped = instance.model_dump() assert dumped == {"inner": {"a": 1}, "b": "hi"} # --- create_schema edge cases --- def test_create_schema_no_config(): s = create_schema("NoConfig", x=(int, Field(...))) assert s.model_config == {"extra": "allow"} def test_create_schema_bad_field(): with pytest.raises(ValueError, match="must be"): create_schema("Bad", x="not a tuple") def test_create_schema_with_alias_generator(): s = create_schema( "AliasGen", __config__={"extra": "forbid", "alias_generator": lambda n: n.upper()}, my_field=(int, Field(...)), ) assert s.model_fields["my_field"].alias == "MY_FIELD" def test_create_schema_plain_default(): """Non-FieldInfo second element in tuple gets wrapped.""" s = create_schema("PlainDefault", x=(int, 42)) assert s.model_fields["x"].default == 42 # --- resolve_type_hints fallback --- def test_from_function_unresolvable_forward_ref(): """Forward refs that can't be resolved fall back to raw annotations.""" # Create a function with an annotation that can't be resolved def func(x: "NonExistentType") -> None: # noqa: F821 pass schema = Schema.from_function(func) # Should still create the schema, just with Any or the raw annotation assert "x" in schema.model_fields # --- _error_type_for branches --- def test_validate_type_str_error(): err = validate_type(42, str) assert err is not None def test_validate_type_float_error(): err = validate_type("abc", float) assert err is not None def test_validate_type_bool_error(): err = validate_type(1, bool) assert err is not None def test_validate_type_complex_error(): """Non-primitive type should give value_error.""" from typing import List err = validate_type("not a list", List[int]) assert err is not None # --- _validate_schema error types --- def test_validate_schema_str_error_type(): f = FieldInfo(default=...) f.annotation = str errors = _validate_schema({"x": 42}, {"x": f}, {"extra": "allow"}, None) assert errors[0]["type"] == "string_type" def test_validate_schema_float_error_type(): f = FieldInfo(default=...) f.annotation = float errors = _validate_schema({"x": "abc"}, {"x": f}, {"extra": "allow"}, None) assert errors[0]["type"] == "float_parsing" def test_validate_schema_bool_error_type(): f = FieldInfo(default=...) f.annotation = bool errors = _validate_schema({"x": 1}, {"x": f}, {"extra": "allow"}, None) assert errors[0]["type"] == "bool_type" def test_validate_schema_positive_int_error_type(): from confection.validation import PositiveInt f = FieldInfo(default=...) f.annotation = PositiveInt errors = _validate_schema({"x": -1}, {"x": f}, {"extra": "allow"}, None) assert errors[0]["type"] == "int_parsing" def test_validate_schema_strict_float_error_type(): from confection.validation import StrictFloat f = FieldInfo(default=...) f.annotation = StrictFloat errors = _validate_schema({"x": "abc"}, {"x": f}, {"extra": "allow"}, None) assert errors[0]["type"] == "float_parsing" def test_validate_schema_generic_error_type(): from typing import List f = FieldInfo(default=...) f.annotation = List[int] errors = _validate_schema({"x": "nope"}, {"x": f}, {"extra": "allow"}, None) assert errors[0]["type"] == "value_error" # --- _is_pydantic_model edge cases --- def test_is_pydantic_model_non_type(): assert _is_pydantic_model("not a type") is False def test_is_pydantic_model_schema_subclass(): class MySchema(Schema): x: int assert _is_pydantic_model(MySchema) is False # --- _pydantic_instance_to_dict --- def test_pydantic_instance_to_dict_v2(): import pydantic class M(pydantic.BaseModel): x: int = 1 assert _pydantic_instance_to_dict(M()) == {"x": 1} @pytest.mark.skipif( sys.version_info >= (3, 14), reason="pydantic v1 not compatible with 3.14+" ) def test_pydantic_instance_to_dict_v1(): from pydantic.v1 import BaseModel class M(BaseModel): x: int = 1 assert _pydantic_instance_to_dict(M()) == {"x": 1} def test_pydantic_instance_to_dict_plain(): assert _pydantic_instance_to_dict(42) == 42 # --- ensure_schema passthrough for non-pydantic --- def test_ensure_schema_plain_class(): class NotAModel: pass assert ensure_schema(NotAModel) is NotAModel # --- v1 allow_none (Optional) --- @pytest.mark.skipif( sys.version_info >= (3, 14), reason="pydantic v1 not compatible with 3.14+" ) def test_v1_optional_field(): from pydantic.v1 import BaseModel as V1Model class M(V1Model): x: Optional[int] = None converted = ensure_schema(M) # Should accept None result = converted.model_validate({"x": None}) assert result.x is None # --- v2 model with pydantic instance default --- def test_v2_pydantic_instance_default(): import pydantic class Inner(pydantic.BaseModel): x: int = 1 class Outer(pydantic.BaseModel): inner: Inner = Inner() converted = ensure_schema(Outer) # Default should be converted from pydantic instance to dict assert converted.model_fields["inner"].default == {"x": 1} # --- v1 model with pydantic instance default --- @pytest.mark.skipif( sys.version_info >= (3, 14), reason="pydantic v1 not compatible with 3.14+" ) def test_v1_pydantic_instance_default(): from pydantic.v1 import BaseModel as V1Model class Inner(V1Model): x: int = 1 class Outer(V1Model): inner: Inner = Inner() converted = ensure_schema(Outer) assert converted.model_fields["inner"].default == {"x": 1} # --- Schema inheritance --- class BaseSchema(Schema): x: int = 1 class DerivedSchema(BaseSchema): y: str = "hello" def test_schema_inheritance(): assert "x" in DerivedSchema.model_fields assert "y" in DerivedSchema.model_fields assert DerivedSchema.model_fields["x"].default == 1 def test_schema_inheritance_override(): class Override(BaseSchema): x: int = 99 assert Override.model_fields["x"].default == 99