From 025db204789423dbb13ad00ebd21b8993074119f Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:21:53 -0400 Subject: [PATCH 1/9] Add path fields for configuration --- .vscode/settings.json | 6 +- comprehensiveconfig/json.py | 3 + comprehensiveconfig/spec.py | 117 ++++++++++++++++++++++++++++++ comprehensiveconfig/toml.py | 3 +- comprehensiveconfig/utility.py | 2 + comprehensiveconfig/validators.py | 111 ++++++++++++++++++++++++++++ tests/conftest.py | 2 +- tests/test_validators.py | 85 ++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 comprehensiveconfig/validators.py create mode 100644 tests/test_validators.py diff --git a/.vscode/settings.json b/.vscode/settings.json index ca7926b..3c01ed9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,7 @@ { - "mypy.enabled": true + "mypy.enabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true + } } \ No newline at end of file diff --git a/comprehensiveconfig/json.py b/comprehensiveconfig/json.py index 0c617c2..bd32601 100644 --- a/comprehensiveconfig/json.py +++ b/comprehensiveconfig/json.py @@ -1,5 +1,6 @@ from datetime import datetime import json +from pathlib import Path from typing import Any from . import configio from . import spec @@ -31,6 +32,8 @@ def dump_value(cls, node: spec.AnyConfigField, value): return value.name case spec.ConfigEnum(_, False): return value.value + case Path(): + return str(value) case str() | int() | float() | bool() | datetime() | dict() | None: return value case _: diff --git a/comprehensiveconfig/spec.py b/comprehensiveconfig/spec.py index 8ad5c37..9a29671 100644 --- a/comprehensiveconfig/spec.py +++ b/comprehensiveconfig/spec.py @@ -1,11 +1,20 @@ from abc import ABC, ABCMeta, abstractmethod import enum +from pathlib import Path import re +import sys from types import UnionType import types from typing import Any, Protocol, Self, Type, Union import typing +from comprehensiveconfig.validators import ( + validate_path_agnostic, + validate_path_sys_aware, + validate_path_unix, + validate_path_windows, +) + class _NoDefaultValueT: """Represents not having a default value. @@ -636,6 +645,8 @@ class Text(ConfigurationField): _holds: str + _regex_pattern: str + def __init__( self, default_value: str | _NoDefaultValueT = NoDefaultValue, @@ -665,6 +676,111 @@ def _validate_value(self, value: Any, name: str | None = None, /): ) +class PathField(ConfigurationField): + """A Folder/file Path that is validated to ensure it is a valid* filepath + validity does not mean the path exists. + + __This is not a bug__ + + This is to allow users of this class to decide how *they* + want to handle file/folders not existing. + For example, they might want to create the folder themselves. + A user might also be referencing a file on a *different* filesystem! + """ + + __slots__ = "_path_type", "_path_validator" + + class PathType(enum.IntEnum): + """Determines what you want the path's to point to (files or directories)""" + + directory = enum.auto() + file = enum.auto() + directory_or_file = enum.auto() + """Disables this type of check""" + + class PathValidator(enum.IntEnum): + """Determines which validation strategy for the path""" + + windows = enum.auto() + unix = enum.auto() + agnostic = enum.auto() + """Doesn't care if the path is for windows or unix/linux""" + current_system = enum.auto() + """ensures that the path is valid for the current system/os.""" + + _holds: Path + + _path_type: PathType + _path_validator: PathValidator + + def __init__( + self, + default_value: str | Path | _NoDefaultValueT = NoDefaultValue, + /, + path_type: PathType = PathType.directory_or_file, + path_validator: PathValidator = PathValidator.agnostic, + *args, + **kwargs, + ): + super().__init__(default_value, *args, **kwargs) + self._path_type = path_type + self._path_validator = path_validator + + def __get__(self, instance, owner) -> Path: + return super().__get__(instance, owner) + + def __set__(self, instance, value: str | Path): + if isinstance(value, str): + value = Path(value) + super().__set__(instance, value) + + def _validate_value(self, value: Any, name: str | None = None, /): + super()._validate_value(value) + + if isinstance(value, str): + value = Path(value) + + if not isinstance(value, Path): + raise ValueError( + f"Field: {name or self._name}\nValue was not a valid Path object: {value}" + ) + + is_valid = True + path_type_name = "" + + # Validate the file path for the specified system + match self._path_validator: + case PathField.PathValidator.windows: + is_valid = validate_path_windows(str(value)) + path_type_name = "windows " + case PathField.PathValidator.unix: + is_valid = validate_path_unix(str(value)) + path_type_name = "unix " + case PathField.PathValidator.agnostic: + is_valid = validate_path_agnostic(str(value)) + case PathField.PathValidator.current_system: + is_valid = validate_path_sys_aware(str(value)) + path_type_name = "windows " if sys.platform == "win32" else "unix " + + if not is_valid: + raise ValueError( + f"Field: {name or self._name}\nValue was not a valid {path_type_name}path: {value}" + ) + + # verify the type of object the path points to is what we expect. + match self._path_type: + case PathField.PathType.directory: + if value.is_file(): + raise ValueError( + f"Field: {name or self._name}\nValue was not a valid directory: {value}" + ) + case PathField.PathType.file: + if value.is_dir(): + raise ValueError( + f"Field: {name or self._name}\nValue was not a valid file: {value}" + ) + + class ConfigUnion[L, R](ConfigurationField): """union field""" @@ -848,6 +964,7 @@ def _validate_value(self, value: Any, name: str | None = None, /): "Table", "TableSpec", "List", + "PathField", "ConfigEnum", "ConfigObject", ] diff --git a/comprehensiveconfig/toml.py b/comprehensiveconfig/toml.py index 3b4001b..2a43c15 100644 --- a/comprehensiveconfig/toml.py +++ b/comprehensiveconfig/toml.py @@ -1,4 +1,5 @@ import enum +from pathlib import Path import tomllib from typing import Any from . import configio @@ -74,7 +75,7 @@ def format_value(cls, field, value) -> str: match value: case bool(): return "true" if value else "false" - case int() | float(): + case int() | float() | Path(): return str(value) case str(): return f'"{escape(value)}"' diff --git a/comprehensiveconfig/utility.py b/comprehensiveconfig/utility.py index 4e66a8a..4f2d11d 100644 --- a/comprehensiveconfig/utility.py +++ b/comprehensiveconfig/utility.py @@ -36,6 +36,8 @@ class Bar(comprehensiveconfig.spec.Section, name="burger"): [10, 20, 30], inner_type=comprehensiveconfig.spec.Integer() ) + test_path = comprehensiveconfig.spec.PathField("C:/Windows/") + test_enum_value = comprehensiveconfig.spec.ConfigEnum( ExampleEnum, ExampleEnum.example ) diff --git a/comprehensiveconfig/validators.py b/comprehensiveconfig/validators.py new file mode 100644 index 0000000..d12613c --- /dev/null +++ b/comprehensiveconfig/validators.py @@ -0,0 +1,111 @@ +"""Additional validators used in more specialty field classes""" + +import sys + +NULL_BYTE = "\x00" + +DEVICE_PATH_PREFIX = "\\\\?\\" +DEVICE_PATH_NORMALIZED_PREFIX = "\\\\.\\" +BANNED_WINDOWS_NAMES = ( + "CON", + "PRN", + "AUX", + "NUL", + "COM0", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT0", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +) + + +def validate_path_unix(path: str) -> bool: + """Takes in a str filepath and validates whether or not it is valid on unix/linux""" + if NULL_BYTE in path: # only invalid character + return False + + return True + + +def validate_path_windows(path: str) -> bool: + """Takes in a str filepath and validates whether or not it is valid on windows. + Allows absolute, relative, device, and normalized device paths. + """ + + # Start seperating text/tokenize it into sections for verification + current_text = "" + tokens: list[str] = [] + + if len(path) > 32_767: # Maximum path size check + return False + + if path.startswith(DEVICE_PATH_PREFIX): + path = path.removeprefix(DEVICE_PATH_PREFIX) + + if path.startswith(DEVICE_PATH_NORMALIZED_PREFIX): + path = path.removeprefix(DEVICE_PATH_NORMALIZED_PREFIX) + + for char in path: + if char == "\\" or char == "/": + if len(current_text) > 0: + tokens.append(current_text) + current_text = "" + continue + if char == ":": + if len(current_text) == 0: + return False + tokens.append(current_text + char) + current_text = "" + continue + if char in '<>"|?*': + return False # all of these are invalid characters. + if ord(char) <= 31: + return False + current_text += char + tokens.append(current_text) + + # iterate over our tokens and validate them. + for c, token in enumerate(tokens): + if ":" in token: # Disallow use of the colon character + if ( + token.count(":") == 1 and token.endswith(":") and c == 0 + ): # only allow a trailing colon on the first token. (drive letter typically) + continue + return False + if token.endswith(".") and token != ".." and token != ".": + return False + if len(token) > 255: + return False + if token in BANNED_WINDOWS_NAMES: + return False + if token.endswith(" "): + return False + return True + + +def validate_path_agnostic(path: str) -> bool: + """Takes in a str filepath and validates whether or not it is valid on any operating system""" + return validate_path_unix(path) or validate_path_windows(path) + + +def validate_path_sys_aware(path: str) -> bool: + """Takes in a str filepath and validates whether or not it is valid on the *current* operating system""" + if sys.platform == "win32": + return validate_path_windows(path) + else: + return validate_path_unix(path) diff --git a/tests/conftest.py b/tests/conftest.py index 591e04b..4a43144 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ @pytest.fixture(autouse=True, scope="function") -def managed_context(filename, writer): +def managed_context(*_): """Manages our files per test""" os.makedirs(OUTPUT_DIR, exist_ok=True) yield diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..8c2df5a --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,85 @@ +import comprehensiveconfig.validators as validators + + +def test_run_passing_unix_validator(): + assert validators.validate_path_unix( + 'some/kind-of/example..../*/1234567890/?&^%$#@!!!<>":: \n []{}/test.txt' + ) + + +def test_run_failing_unix_validator(): + assert not validators.validate_path_unix( + 'some/kind-of/example..../*/1234567890/?&^%$#@!!!<>":: \n []{}/test.txt\x00' + ) + + +# * windows +def test_run_passing_windows_validator(): + assert validators.validate_path_windows("D:/Windows/System32/WoahThatsCool.md") + + assert validators.validate_path_windows( + "\\\\?\\D:/Windows/System32/WoahThatsCool.md" + ) + assert validators.validate_path_windows( + "\\\\.\\D:/Windows/System32/WoahThatsCool.md" + ) + + assert validators.validate_path_windows("/Windows/System32/WoahThatsCool.md") + assert validators.validate_path_windows("/Windows/System32/WoahThatsCool.md/") + assert validators.validate_path_windows("//Windows/System32/WoahThatsCool.md/") + assert validators.validate_path_windows("//Windows/System32/WoahThatsCool.md/../") + assert validators.validate_path_windows("//Windows/System32/WoahThatsCool.md/.././") + assert validators.validate_path_windows( + "//Windows/System32/WoahThatsCool.md/.././ x" + ) + + +def test_run_failing_windows_validator(): + # trailing spaces + assert not validators.validate_path_windows( + "D:/Windows/System32 /WoahThatsCool.md" + ) + assert not validators.validate_path_windows( + "D:/Windows/System32/WoahThatsCool.md " + ) + + # invalid chars + assert not validators.validate_path_windows("D:/Windows/System32/WoahThatsCool<.md") + assert not validators.validate_path_windows("D:/Windows/System32/WoahThatsCool>.md") + assert not validators.validate_path_windows('D:/Windows/System32/WoahThatsCool".md') + assert not validators.validate_path_windows("D:/Windows/System32/WoahThatsCool?.md") + assert not validators.validate_path_windows("D:/Windows/System32/WoahThatsCool*.md") + assert not validators.validate_path_windows( + "D:/Windows/System32/WoahThatsCool.md\n" + ) + assert not validators.validate_path_windows( + "D:/Windows/System32/WoahThatsCool.md\t" + ) + assert not validators.validate_path_windows( + "D:/Windows/System32/WoahThatsCool.md\x00" + ) + + # single/leading colon + assert not validators.validate_path_windows(":/Windows/System32/WoahThatsCool.md") + assert not validators.validate_path_windows( + "/balls/:er/Windows/System32/WoahThatsCool.md" + ) + assert not validators.validate_path_windows( + "/balls/:/Windows/System32/WoahThatsCool.md" + ) + + # illegal names + assert not validators.validate_path_windows( + "D:/Windows/System32/WoahThatsCool.md/CON/" + ) + + # individual folder/file name too large + assert not validators.validate_path_windows("C:/Windows/" + "x" * 256) + + # path too long + assert not validators.validate_path_windows("x" * 32_768) + + # trailing dots + assert not validators.validate_path_windows( + "D:/Windows/System32/WoahThatsCool.md/thing./" + ) diff --git a/uv.lock b/uv.lock index b2bb215..19b8f3e 100644 --- a/uv.lock +++ b/uv.lock @@ -138,7 +138,7 @@ wheels = [ [[package]] name = "comprehensiveconfig" -version = "1.1.2" +version = "1.1.3" source = { virtual = "." } [package.dev-dependencies] From f8a91a25f424b39e7c776c54f12f086cbf9426f7 Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:04:30 -0400 Subject: [PATCH 2/9] document new stuff --- comprehensiveconfig/__init__.py | 2 ++ comprehensiveconfig/validators.py | 16 ++++++--- docs/source/fields.rst | 54 +++++++++++++++++++++++++++++++ docs/source/globaltoc.rst | 3 +- docs/source/index.rst | 1 + docs/source/validators.rst | 38 ++++++++++++++++++++++ readme.md | 9 ++++-- 7 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 docs/source/validators.rst diff --git a/comprehensiveconfig/__init__.py b/comprehensiveconfig/__init__.py index 832cde7..7e8aec6 100644 --- a/comprehensiveconfig/__init__.py +++ b/comprehensiveconfig/__init__.py @@ -4,6 +4,7 @@ from . import configio from . import spec +from . import validators from .json import JsonWriter from .toml import TomlWriter @@ -147,6 +148,7 @@ def reset_global(cls): "_ConfigSpecMeta", "_ConfigSpecABCMeta", "spec", + "validators", "configio", "JsonWriter", "TomlWriter", diff --git a/comprehensiveconfig/validators.py b/comprehensiveconfig/validators.py index d12613c..9b3e4de 100644 --- a/comprehensiveconfig/validators.py +++ b/comprehensiveconfig/validators.py @@ -2,8 +2,9 @@ import sys +# unix NULL_BYTE = "\x00" - +# windows DEVICE_PATH_PREFIX = "\\\\?\\" DEVICE_PATH_NORMALIZED_PREFIX = "\\\\.\\" BANNED_WINDOWS_NAMES = ( @@ -36,10 +37,7 @@ def validate_path_unix(path: str) -> bool: """Takes in a str filepath and validates whether or not it is valid on unix/linux""" - if NULL_BYTE in path: # only invalid character - return False - - return True + return NULL_BYTE not in path def validate_path_windows(path: str) -> bool: @@ -109,3 +107,11 @@ def validate_path_sys_aware(path: str) -> bool: return validate_path_windows(path) else: return validate_path_unix(path) + + +__all__ = [ + "validate_path_unix", + "validate_path_windows", + "validate_path_agnostic", + "validate_path_sys_aware", +] diff --git a/docs/source/fields.rst b/docs/source/fields.rst index fa19128..c13a027 100644 --- a/docs/source/fields.rst +++ b/docs/source/fields.rst @@ -120,6 +120,9 @@ Module .. py:attribute:: _holds :type: str + .. py:attribute:: _regex_pattern + :type: str + .. py:class:: comprehensiveconfig.spec.List[T](default_value: list[T] = [], /, inner_type: AnyConfigField | None = None, **kwargs) @@ -133,6 +136,57 @@ Module Might require manual annotation if your default value remains an empty list +.. py:class:: comprehensiveconfig.spec.PathField(default_value: Path | str | _NoDefaultValueT = NoDefaultValue, /, path_type: PathField.PathType = PathField.directory_or_file, path_validator: PathField.PathValidator = PathValidator.agnostic, **kwargs) + + :param Path | str | _NoDefaultValueT default_value: The default for this field. + :param PathField.PathType path_type: Whether you want to only accept files, directories, or both + :param PathField.PathValidator path_validator: Determines how it should validate the path string. + + .. py:attribute:: _holds + :type: Path + + .. py:attribute:: _path_type + :type: PathField.PathType + + .. py:attribute:: _path_validator + :type: PathField.PathValidator + +.. py:class:: comprehensiveconfig.spec.PathField.PathType + + This is an enum representing the types of paths we would like to support in this field. + + .. py:data:: directory + + We only want paths to point to directories! + + .. py:data:: file + + We only want paths to point to files! + + .. py:data:: directory_or_file + + Disables specific checks for what the paths are pointing to. + +.. py:class:: comprehensiveconfig.spec.PathField.PathValidator + + This is an enum representing the different path validators defined in :py:mod:`comprehensiveconfig.validators` + + .. py:data:: windows + + Paths should only be valid Windows paths. + + .. py:data:: unix + + Paths should only be valid Unix Paths + + .. py:data:: agnostic + + Checks for validity on Unix or Windows (only one needs to be valid). + + .. py:data:: current_system + + Uses the validator corresponding to the current current system. + .. py:class:: comprehensiveconfig.spec.Table[K, V](default_value: dict[K, V] = {}, /, key_type: AnyConfigField | None = None, value_type: AnyConfigField | None = None, **kwargs) :param dict[K, V] default_value: The default value of the field. This always default to an empty dict (required for static type checking) diff --git a/docs/source/globaltoc.rst b/docs/source/globaltoc.rst index 8b43376..cbb6542 100644 --- a/docs/source/globaltoc.rst +++ b/docs/source/globaltoc.rst @@ -25,4 +25,5 @@ :maxdepth: 2 :caption: Utilities: - utilities.rst \ No newline at end of file + utilities.rst + validators.rst \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index a3c088a..f8e0848 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -41,6 +41,7 @@ loading as well as complex validators for incoming configuration values. :caption: Utilities: utilities.rst + validators.rst Module ******** diff --git a/docs/source/validators.rst b/docs/source/validators.rst new file mode 100644 index 0000000..2ba4bde --- /dev/null +++ b/docs/source/validators.rst @@ -0,0 +1,38 @@ +Validators +=========== + +This file primarily contains various validators used throughout the spec. + +.. py:currentmodule:: comprehensiveconfig.validators + + +Module +******** + +.. py:function:: validate_path_unix(path: str) -> bool + + This function takes a string path and validates whether or not it would be valid on a Unix system. + This is a simple check to see if the path contains a null byte (:code:`\x00`). + + :param str path: The path as a :bold:`string`. Path objects do not work here! + + +.. py:function:: validate_path_windows(path: str) -> bool + + This function takes a string path and validates whether or not it would be valid on a windows system. + This is a muchmore complex function than the one for Unix systems. + + :param str path: The path as a :bold:`string`. Path objects do not work here! + + +.. py:function:: validate_path_agnostic(path: str) -> bool + + This function checks if a path would be valid on Unix OR Windows. It first runs the check for Unix (its less expensive) before then checking for validity on Windows. + + :param str path: The path as a :bold:`string`. Path objects do not work here! + +.. py:function:: validate_path_sys_aware(path: str) -> bool + + Another path validator function but this one determines the system you are currently on before choosing which validator to use. + + :param str path: The path as a :bold:`string`. Path objects do not work here! \ No newline at end of file diff --git a/readme.md b/readme.md index f4e4bf5..44ae1e7 100644 --- a/readme.md +++ b/readme.md @@ -13,8 +13,11 @@ A simple configuration library that lets you create a pydantic-like model for yo - [x] Supports static type checking - [x] toml writer - [x] json writer -- [x] Number Fields -- [x] Text Fields (with regex filtering) +- [x] Number fields +- [x] Text fields (with regex filtering) +- [x] File/Folder path fields (`PathField`) + - [x] Ability to change validator (unix/linux, windows, agnostic, and current system) + - [x] Validate for only files, folder, or accept both. - [x] List fields - [x] Table fields - [x] TableSpec (Model) fields @@ -22,7 +25,7 @@ A simple configuration library that lets you create a pydantic-like model for yo - [x] Include doc comments in Section - [x] auto loading - [x] initialize default config (with auto loader) -- [ ] yaml writer +- [N/a] yaml writer (This will likely be moved to a different library as an extension of comprehensiveconfig!) - [ ] Tests targetting mypy and other static type checkers to ensure EVERYTHING looks good across IDE's - [x] section list (via a Table field) - [x] Field type unions (overwriting normal union syntax) From 4c74be6a8731a5100bacc832f830b8d9cf1bcf02 Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:28:37 -0400 Subject: [PATCH 3/9] small typing changes --- comprehensiveconfig/__init__.py | 33 +++++--------- comprehensiveconfig/spec.py | 81 ++++++--------------------------- 2 files changed, 26 insertions(+), 88 deletions(-) diff --git a/comprehensiveconfig/__init__.py b/comprehensiveconfig/__init__.py index 7e8aec6..5e85411 100644 --- a/comprehensiveconfig/__init__.py +++ b/comprehensiveconfig/__init__.py @@ -1,4 +1,3 @@ -from abc import ABCMeta import os from typing import Any, Self, Type, Union @@ -9,7 +8,7 @@ from .toml import TomlWriter -class _ConfigSpecMeta(type): +class _ConfigSpecMeta(spec.ConfigSectionMeta): """handles getting of attributes""" _WRITER: Type[configio.ConfigurationWriter] | None @@ -22,24 +21,20 @@ def __new__( cls, name, bases, - attrs, + namespace, default_file: str | None = None, # automatically load a specific file writer=None, create_file: bool = False, # create the default file if not exists auto_load: bool = True, **kwargs, ): - cls._DEFAULT_FILE = default_file - cls._WRITER = writer - cls._INST = None - cls._CREATE_FILE = create_file - cls._AUTO_LOAD = auto_load - return super().__new__(cls, name, bases, attrs) - - def __get__(self, instance, owner): - if self._INST is None: - return self - return self._INST + namespace["_INST"] = None + namespace["_DEFAULT_FILE"] = default_file + namespace["_WRITER"] = writer + namespace["_CREATE_FILE"] = create_file + namespace["_AUTO_LOAD"] = auto_load + + return super().__new__(cls, name, bases, namespace) def __getattribute__(self, name): """get attributes from active instance if available""" @@ -58,18 +53,14 @@ def __setattr__(self, name, value): return super().__setattr__(name, value) -class _ConfigSpecABCMeta(spec.ConfigurationFieldABCMeta, _ConfigSpecMeta): - """A combination of ABCMeta and config spec meta""" - - -class ConfigSpec(spec.Section, metaclass=_ConfigSpecABCMeta): +class ConfigSpec(spec.Section, metaclass=_ConfigSpecMeta): @classmethod def __init_subclass__( cls, **kwargs, ): - super().__init_subclass__(**kwargs) + super().__init_subclass__() if not cls._AUTO_LOAD: return @@ -84,6 +75,7 @@ def __init_subclass__( cls._INST = cls(cls._WRITER.load(cls._DEFAULT_FILE)) if not exists and cls._CREATE_FILE: default = cls() + print(cls._WRITER) cls._WRITER.dump(cls._DEFAULT_FILE, default) cls._INST = default if not exists and not cls._CREATE_FILE: @@ -146,7 +138,6 @@ def reset_global(cls): __all__ = [ "ConfigSpec", "_ConfigSpecMeta", - "_ConfigSpecABCMeta", "spec", "validators", "configio", diff --git a/comprehensiveconfig/spec.py b/comprehensiveconfig/spec.py index 9a29671..634b590 100644 --- a/comprehensiveconfig/spec.py +++ b/comprehensiveconfig/spec.py @@ -60,9 +60,6 @@ class BaseConfigurationField(ABC): __slots__ = ("_field_variable", "_parent", "_value") - _parent: Type["BaseConfigurationField"] | None - """The parent to this node""" - _field_variable: None | str """The python variable that this field is attached to""" @@ -91,6 +88,8 @@ class ConfigurationField[T](BaseConfigurationField): "_inline_doc", ) + _parent: "ConfigSectionMeta | None" + """The parent to this node""" _name: None | str """The actual name used inside the configuration This has to be valid for whatever config format you use""" @@ -181,18 +180,19 @@ def __set__(self, instance, value): instance._instance_parent = value -class Section(BaseConfigurationField, metaclass=ConfigurationFieldABCMeta): - """A baseclass for sections to be defined""" - - __slots__ = "_value" - +class ConfigSectionMeta(ConfigurationFieldABCMeta): _FIELDS: dict[str, ConfigurationField] - _SECTIONS: dict[str, Type] - _ALL_FIELDS: dict[str, AnyConfigField | Type] + _SECTIONS: dict[str, "ConfigSectionMeta"] + _ALL_FIELDS: dict[str, "ConfigurationField | ConfigSectionMeta"] _FIELD_NAME_MAP: dict[str, str] """Maps config names to their actual variable names""" _FIELD_VAR_MAP: dict[str, str] """Maps variable names to their actual config names""" + +class Section(BaseConfigurationField, metaclass=ConfigSectionMeta): + """A baseclass for sections to be defined""" + + __slots__ = "_value" _name = SectionName() """The name in the configuration file (chooses between _real_name and _cls_name)""" _cls_name: str @@ -220,14 +220,14 @@ def __init_subclass__(cls, name: str | None = None, **kwargs): cls._SECTIONS = { field_name: field for field_name, field in cls.__dict__.items() - if isinstance(field, type) and Section in field.__mro__ + if isinstance(field, ConfigSectionMeta) and Section in field.__mro__ } cls._ALL_FIELDS = cls._FIELDS | cls._SECTIONS for name, field in cls._ALL_FIELDS.items(): field._field_variable = name if field._name is None: field._name = name - if isinstance(field, type): + if isinstance(field, ConfigSectionMeta): field._cls_parent = cls else: field._parent = cls @@ -330,64 +330,11 @@ def nullable(self): return False -class List[T](ConfigurationField): - """List field""" - - __slots__ = "inner_type" - - _holds: list[T] - - def __init__( - self, - default_value: list[T] = [], - /, - inner_type: AnyConfigField | None = None, - *args, - **kwargs, - ): - self.inner_type = fix_unions(inner_type) - - return super().__init__(default_value, *args, **kwargs) - - def __call__(self, value: list[T]) -> list[T]: - return [self.inner_type(val) for val in value] - - def __get__(self, instance, owner) -> list[T]: - return super().__get__(instance, owner) - - def __set__(self, instance, value: list[T]): - super().__set__(instance, value) - - def _validate_value(self, value: Any, name: str | None = None, /): - super()._validate_value(value) - if not isinstance(value, list): - raise ValueError( - f"Field: {name or self._name}\nValue was not a valid list: {value}" - ) - - match self.inner_type: - case None: - return - case type(): - raise ValueError(self.inner_type) - - case BaseConfigurationField(): - for c, item in enumerate(value): - self.inner_type._validate_value(item, f"{name or self._name}[{c}]") - - -class TableSpec(ConfigurationField, metaclass=ConfigurationFieldABCMeta): +class TableSpec(ConfigurationField, metaclass=ConfigSectionMeta): """A model/Table""" __slots__ = () - _FIELDS: dict[str, AnyConfigField] - _SECTIONS: dict[str, Type] - _ALL_FIELDS: dict[str, AnyConfigField | Type] - _FIELD_NAME_MAP: dict[str, str] - """Maps config names to their actual variable names""" - _FIELD_VAR_MAP: dict[str, str] - """Maps variable names to their actual config names""" _cls_name: str """The actual name in the configuration file""" _cls_has_default: bool @@ -408,7 +355,7 @@ def __init_subclass__(cls, name: str | None = None, **kwargs): cls._SECTIONS = { field_name: field for field_name, field in cls.__dict__.items() - if isinstance(field, type) and Section in field.__mro__ + if isinstance(field, ConfigSectionMeta) and Section in field.__mro__ } cls._ALL_FIELDS = cls._FIELDS | cls._SECTIONS for name, field in cls._ALL_FIELDS.items(): From a65e67426c5c018e10bc988a3979abe3074d7e72 Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:26:19 -0400 Subject: [PATCH 4/9] Add a simple work around for type checkers that don't play nice. Also add warning about Mypy issues. --- comprehensiveconfig/__init__.py | 23 ++++++++++++++++++++--- comprehensiveconfig/toml.py | 2 +- docs/source/index.rst | 5 +++-- readme.md | 20 ++++++++++++++++++++ testing.py | 6 ++++-- 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/comprehensiveconfig/__init__.py b/comprehensiveconfig/__init__.py index 5e85411..8e4d4b8 100644 --- a/comprehensiveconfig/__init__.py +++ b/comprehensiveconfig/__init__.py @@ -8,6 +8,17 @@ from .toml import TomlWriter +def autoloaded[T: "ConfigSpec"](cls: type[T]) -> T: + """Acts as a temporary fix to issues with autoloaded configuration classes in + certain type checkers. This was originally intended to fix mypy typechecking, + but mypy doesn't actually check class decorators.""" + if not cls._AUTO_LOAD: + raise ValueError("Decorator expects an autoloaded configuration class.") + if cls._INST is None: + raise ValueError("Instance was not loaded somehow.") + return cls + + class _ConfigSpecMeta(spec.ConfigSectionMeta): """handles getting of attributes""" @@ -60,7 +71,7 @@ def __init_subclass__( cls, **kwargs, ): - super().__init_subclass__() + super().__init_subclass__(**kwargs) if not cls._AUTO_LOAD: return @@ -75,7 +86,6 @@ def __init_subclass__( cls._INST = cls(cls._WRITER.load(cls._DEFAULT_FILE)) if not exists and cls._CREATE_FILE: default = cls() - print(cls._WRITER) cls._WRITER.dump(cls._DEFAULT_FILE, default) cls._INST = default if not exists and not cls._CREATE_FILE: @@ -88,7 +98,7 @@ def __init__(self, value: dict[str, Any] | None = None, /): super().__init__(value or self._default_value) @classmethod - def load(cls, file=None, writer=None, /) -> Self: + def load(cls, file=None, writer=None, /, create_file: bool = False) -> Self: file = file or cls._DEFAULT_FILE writer = writer or cls._WRITER @@ -97,6 +107,13 @@ def load(cls, file=None, writer=None, /) -> Self: if file is None: raise Exception("No file specified") + if cls._CREATE_FILE: + exists = os.path.exists(file) + if not exists: + inst = cls() + writer.dump(cls._DEFAULT_FILE, inst) + return inst + return cls(writer.load(file)) def save(self, file=None, writer=None, /): diff --git a/comprehensiveconfig/toml.py b/comprehensiveconfig/toml.py index 2a43c15..6486ab6 100644 --- a/comprehensiveconfig/toml.py +++ b/comprehensiveconfig/toml.py @@ -153,7 +153,7 @@ def dump_field(cls, field: spec.AnyConfigField, value) -> str: return cls.dump_table(table_node, value) case spec.Section(): return "\n".join(cls.dump_section(field)) - case spec.ConfigEnum(_, by_name): + case spec.ConfigEnum(_, __): return cls.dump_enum(field, value) case spec.ConfigurationField(): # magic method to make writing new field types possible diff --git a/docs/source/index.rst b/docs/source/index.rst index f8e0848..c0ace1e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -84,9 +84,10 @@ Module Load a specified file (or load default file with default writer) - .. py:method:: save(file=None, writer=None, /) + .. py:method:: save(file=None, writer=None, /, create_file=False) - Save a specified file (or save default file with default writer) + Save a specified file (or save default file with default writer). + Has the option to create the file if it does exist. .. py:method:: reset() diff --git a/readme.md b/readme.md index 44ae1e7..ebfc715 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,26 @@ A simple configuration library that lets you create a pydantic-like model for your configuration. +# Autoloaded Configuration Classes (Typing Issues in Mypy) + +There is one particular pitfall I have yet to develop around. Mypy is missing a few essential checks that improve type checking on autoloaded configuration classes. For the time being, I recommend avoiding using mypy with this project until class-decorator annotations work [(something that hasn't worked since 2017)](https://github.com/python/mypy/issues/3135) or until I can manage to create a mypy plugin to work around the issue. Pyright doesn't seem believe anything is wrong. Pyright is the default typechecker used in vscode's pylance extension. + +If type checking issues occur on your autoloaded config classes, try using this temporary decorator as a fix: +```python +@autoloaded # makes your type checker think `MyConfig` is an instance of itself. (or at least it *should* do that.) +class MyConfig(ConfigSpec, default_file="test.toml", writer=TomlWriter, create_file=True): + ... # impl here +``` + +The other option is to just manually load your configuration. + +```python +class MyConfig(ConfigSpec, auto_load=False): + ... + +config = MyConfig.load("test.toml", TomlWriter, create_file=True) # load and/or create our config file +``` + # Installation `pip install comprehensiveconfig` diff --git a/testing.py b/testing.py index 305dec9..ce83146 100644 --- a/testing.py +++ b/testing.py @@ -1,6 +1,6 @@ from enum import Enum -from comprehensiveconfig import ConfigSpec +from comprehensiveconfig import ConfigSpec, autoloaded from comprehensiveconfig.json import JsonWriter from comprehensiveconfig.spec import ( Boolean, @@ -14,6 +14,7 @@ ConfigEnum, ) from comprehensiveconfig.toml import TomlWriter +import comprehensiveconfig.utility class Example(TableSpec): @@ -27,6 +28,7 @@ class testEnum(Enum): bar = "chicken" +@autoloaded class MyConfigSpec( ConfigSpec, default_file="test.toml", writer=TomlWriter, create_file=True ): @@ -66,7 +68,7 @@ class Credentials(Section, name="Credentials"): print(MyConfigSpec.some_field) print(MyConfigSpec.MySection.other_field) -MyConfigSpec.some_field = 12.2 +MyConfigSpec.some_field = 12.0 print("clump") print(MyConfigSpec.some_field) print(MyConfigSpec.MySection.other_field) From eac43de870c9bc8b2fcd72b44beca1b1c9c007d6 Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:51:20 -0400 Subject: [PATCH 5/9] Add better type annotation to `save()` --- comprehensiveconfig/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/comprehensiveconfig/__init__.py b/comprehensiveconfig/__init__.py index 8e4d4b8..bf031ca 100644 --- a/comprehensiveconfig/__init__.py +++ b/comprehensiveconfig/__init__.py @@ -116,7 +116,9 @@ def load(cls, file=None, writer=None, /, create_file: bool = False) -> Self: return cls(writer.load(file)) - def save(self, file=None, writer=None, /): + def save( + self, file=None, writer: Type[configio.ConfigurationWriter] | None = None, / + ): file = file or self._DEFAULT_FILE writer = writer or self._WRITER From fdee02e8098a43ed3ab5066524a30256bc95b40f Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:05:44 -0400 Subject: [PATCH 6/9] Change ConfigUnion type annotations + add typechecking only _validate_value function on ConfigSectionMeta --- comprehensiveconfig/spec.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/comprehensiveconfig/spec.py b/comprehensiveconfig/spec.py index 634b590..0eba02d 100644 --- a/comprehensiveconfig/spec.py +++ b/comprehensiveconfig/spec.py @@ -129,7 +129,7 @@ def _validate_value(self, value: Any, name: str | None = None, /): if value is None and not self._nullable: raise ValueError(f'Field, "{name or self._name}", is not nullable') - def __or__(self, value: "type | AnyConfigField") -> "ConfigUnion": + def __or__(self, value: "ConfigSectionMeta | AnyConfigField") -> "ConfigUnion": return ConfigUnion(self, value) def __set_name__(self, owner, name): @@ -189,6 +189,11 @@ class ConfigSectionMeta(ConfigurationFieldABCMeta): _FIELD_VAR_MAP: dict[str, str] """Maps variable names to their actual config names""" + if typing.TYPE_CHECKING: + + def _validate_value(self, value: Any, name: str | None = None, /): ... + + class Section(BaseConfigurationField, metaclass=ConfigSectionMeta): """A baseclass for sections to be defined""" @@ -735,13 +740,13 @@ class ConfigUnion[L, R](ConfigurationField): _holds: L | R - _left_type: AnyConfigField | Type - _right_type: AnyConfigField | Type + _left_type: BaseConfigurationField | ConfigSectionMeta + _right_type: BaseConfigurationField | ConfigSectionMeta def __init__( self, - left_type: AnyConfigField | Type, - right_type: AnyConfigField | Type, + left_type: AnyConfigField | ConfigSectionMeta, + right_type: AnyConfigField | ConfigSectionMeta, *args, **kwargs, ): From f175d170adfaf10f4af12457356172771d88091e Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:17:52 -0400 Subject: [PATCH 7/9] Move many attributes of `Section` to `ConfigSectionMeta` --- comprehensiveconfig/spec.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/comprehensiveconfig/spec.py b/comprehensiveconfig/spec.py index 0eba02d..25d7fa9 100644 --- a/comprehensiveconfig/spec.py +++ b/comprehensiveconfig/spec.py @@ -158,7 +158,7 @@ class SectionName: """Descriptor for section names. Chooses between class name and instance name automatically""" - def __get__(self, instance, owner): + def __get__(self, instance, owner) -> str: if instance is None: return object.__getattribute__(owner, "_cls_name") return object.__getattribute__(instance, "_instance_name") @@ -188,9 +188,19 @@ class ConfigSectionMeta(ConfigurationFieldABCMeta): """Maps config names to their actual variable names""" _FIELD_VAR_MAP: dict[str, str] """Maps variable names to their actual config names""" + _cls_parent: "BaseConfigurationField | ConfigSectionMeta | None" + _cls_name: str + """The name of the class""" + _field_variable: str + _has_default: bool + _default_value: dict[str, Any] | _NoDefaultValueT + _sorting_order: int if typing.TYPE_CHECKING: + _name = SectionName() + _parent = SectionParent() + def _validate_value(self, value: Any, name: str | None = None, /): ... @@ -200,15 +210,10 @@ class Section(BaseConfigurationField, metaclass=ConfigSectionMeta): __slots__ = "_value" _name = SectionName() """The name in the configuration file (chooses between _real_name and _cls_name)""" - _cls_name: str - """The name of the class""" _instance_name: str """The actual name in the configuration file""" - _has_default: bool - _default_value: dict[str, Any] | _NoDefaultValueT _parent = SectionParent() _instance_parent: AnyConfigField | None - _cls_parent: AnyConfigField | None _sorting_order = 1 @@ -249,7 +254,9 @@ def __init_subclass__(cls, name: str | None = None, **kwargs): cls._has_default = all(field._has_default for field in cls._ALL_FIELDS.values()) if cls._has_default: cls._default_value = { - field._name: field._default_value for field in cls._ALL_FIELDS.values() + field._name: field._default_value + for field in cls._ALL_FIELDS.values() + if field._name is not None } else: cls._default_value = NoDefaultValue @@ -387,7 +394,9 @@ def __init_subclass__(cls, name: str | None = None, **kwargs): ) if cls._cls_has_default: cls._cls_default_value = { - field._name: field._default_value for field in cls._ALL_FIELDS.values() + field._name: field._default_value + for field in cls._ALL_FIELDS.values() + if field._name is not None } else: cls._cls_default_value = NoDefaultValue From 5b82939e4bec2d2412c818fc715cdedffd57188e Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:30:54 -0400 Subject: [PATCH 8/9] overload `__get__` in various places to be more accurate --- comprehensiveconfig/spec.py | 98 +++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/comprehensiveconfig/spec.py b/comprehensiveconfig/spec.py index 25d7fa9..fe52d52 100644 --- a/comprehensiveconfig/spec.py +++ b/comprehensiveconfig/spec.py @@ -5,7 +5,7 @@ import sys from types import UnionType import types -from typing import Any, Protocol, Self, Type, Union +from typing import Any, Protocol, Self, Type, Union, overload, override import typing from comprehensiveconfig.validators import ( @@ -138,7 +138,13 @@ def __set_name__(self, owner, name): if self._name is None: self._name = name - def __get__(self, instance, owner) -> T: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> T: ... + + def __get__(self, instance: "Section | None", owner) -> T | Self: if instance is None: return self # Retrieve the value from the instance's dictionary @@ -193,7 +199,7 @@ class ConfigSectionMeta(ConfigurationFieldABCMeta): """The name of the class""" _field_variable: str _has_default: bool - _default_value: dict[str, Any] | _NoDefaultValueT + _default_value: dict[str | None, Any] | _NoDefaultValueT _sorting_order: int if typing.TYPE_CHECKING: @@ -214,7 +220,7 @@ class Section(BaseConfigurationField, metaclass=ConfigSectionMeta): """The actual name in the configuration file""" _parent = SectionParent() _instance_parent: AnyConfigField | None - + _value: dict[str | None, Any] _sorting_order = 1 @classmethod @@ -395,8 +401,8 @@ def __init_subclass__(cls, name: str | None = None, **kwargs): if cls._cls_has_default: cls._cls_default_value = { field._name: field._default_value - for field in cls._ALL_FIELDS.values() - if field._name is not None + for field in cls._ALL_FIELDS.values() + if field._name is not None } else: cls._cls_default_value = NoDefaultValue @@ -459,7 +465,13 @@ def __call__(self, value: dict[K, V]) -> dict[K, V]: self._validate_value(value, self._name) return {self.key_type(key): self.value_type(val) for key, val in value.items()} - def __get__(self, instance, owner) -> dict[K, V]: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> dict[K, V]: ... + + def __get__(self, instance: "Section | None", owner) -> dict[K, V] | Self: return super().__get__(instance, owner) def __set__(self, instance, value: dict[K, V]): @@ -508,7 +520,13 @@ def __call__(self, value: list[T]) -> list[T]: self._validate_value(value, self._name) return [self.inner_type(val) for val in value] - def __get__(self, instance, owner) -> list[T]: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> list[T]: ... + + def __get__(self, instance: "Section | None", owner) -> list[T] | Self: return super().__get__(instance, owner) def __set__(self, instance, value: list[T]): @@ -539,7 +557,13 @@ class Boolean(ConfigurationField): _holds: bool - def __get__(self, instance, owner) -> bool: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> bool: ... + + def __get__(self, instance: "Section | None", owner) -> bool | Self: return super().__get__(instance, owner) def __set__(self, instance, value: bool): @@ -560,7 +584,13 @@ class Float(ConfigurationField): _holds: float - def __get__(self, instance, owner) -> float: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> float: ... + + def __get__(self, instance: "Section | None", owner) -> float | Self: return super().__get__(instance, owner) def __set__(self, instance, value: float): @@ -581,7 +611,13 @@ class Integer(ConfigurationField): _holds: int - def __get__(self, instance, owner) -> int: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> int: ... + + def __get__(self, instance: "Section | None", owner) -> int | Self: return super().__get__(instance, owner) def __set__(self, instance, value: int): @@ -619,7 +655,13 @@ def __init__( super().__init__(default_value, *args, **kwargs) self._regex_pattern = regex - def __get__(self, instance, owner) -> str: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> str: ... + + def __get__(self, instance: "Section | None", owner) -> str | Self: return super().__get__(instance, owner) def __set__(self, instance, value: str): @@ -687,7 +729,13 @@ def __init__( self._path_type = path_type self._path_validator = path_validator - def __get__(self, instance, owner) -> Path: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> Path: ... + + def __get__(self, instance: "Section | None", owner) -> Path | Self: return super().__get__(instance, owner) def __set__(self, instance, value: str | Path): @@ -772,7 +820,13 @@ def __call__(self, *args, **kwargs): except ValueError: # if left side fails, try the right return self._right_type(*args, **kwargs) - def __get__(self, instance, owner) -> L | R: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> L | R: ... + + def __get__(self, instance: "Section | None", owner) -> L | R | Self: return super().__get__(instance, owner) def __set__(self, instance, value: L | R): @@ -835,7 +889,13 @@ def get_value(self, value: Any): def __call__(self, value: Any): return self.get_value(value) - def __get__(self, instance, owner) -> T: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> T: ... + + def __get__(self, instance: "Section | None", owner) -> T | Self: return super().__get__(instance, owner) def __set__(self, instance, value: T | Any): @@ -898,7 +958,13 @@ def __call__(self, value: Any): return value return self._type.from_config(value) - def __get__(self, instance, owner) -> T: + @overload + def __get__(self, instance: None, owner) -> Self: ... + + @overload + def __get__(self, instance: "Section", owner) -> T: ... + + def __get__(self, instance: "Section | None", owner) -> T | Self: return super().__get__(instance, owner) def __set__(self, instance, value: T | Any): From b6b6753f9483ca31045db9951c8aa5a881a4f960 Mon Sep 17 00:00:00 2001 From: Abby Austin <38941820+spidertyler2005@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:40:39 -0400 Subject: [PATCH 9/9] Version bump, rearrange readme, remove old license. --- LICENSE.txt | 65 -------------------------------------------------- pyproject.toml | 2 +- readme.md | 41 +++++++++++++++---------------- 3 files changed, 22 insertions(+), 86 deletions(-) delete mode 100644 LICENSE.txt diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index aee9916..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,65 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 3, 29 June 2007 - -Copyright © 2007 Free Software Foundation, Inc. - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. -0. Additional Definitions. - -As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License. - -“The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. - -An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. - -A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”. - -The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. - -The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. -1. Exception to Section 3 of the GNU GPL. - -You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. -2. Conveying Modified Versions. - -If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: - - a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or - b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. - -3. Object Code Incorporating Material from Library Header Files. - -The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. - b) Accompany the object code with a copy of the GNU GPL and this license document. - -4. Combined Works. - -You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: - - a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. - b) Accompany the Combined Work with a copy of the GNU GPL and this license document. - c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. - d) Do one of the following: - 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. - 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. - e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) - -5. Combined Libraries. - -You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. - b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -6. Revised Versions of the GNU Lesser General Public License. - -The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. - -If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 42f1d26..509c524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "comprehensiveconfig" -version = "1.1.5" +version = "1.2.0" description = "A library to create ergonomic, auto-validated configuration models with great support for static type annotations." readme = "readme.md" requires-python = ">=3.12" diff --git a/readme.md b/readme.md index ebfc715..acf2c7e 100644 --- a/readme.md +++ b/readme.md @@ -4,26 +4,6 @@ A simple configuration library that lets you create a pydantic-like model for your configuration. -# Autoloaded Configuration Classes (Typing Issues in Mypy) - -There is one particular pitfall I have yet to develop around. Mypy is missing a few essential checks that improve type checking on autoloaded configuration classes. For the time being, I recommend avoiding using mypy with this project until class-decorator annotations work [(something that hasn't worked since 2017)](https://github.com/python/mypy/issues/3135) or until I can manage to create a mypy plugin to work around the issue. Pyright doesn't seem believe anything is wrong. Pyright is the default typechecker used in vscode's pylance extension. - -If type checking issues occur on your autoloaded config classes, try using this temporary decorator as a fix: -```python -@autoloaded # makes your type checker think `MyConfig` is an instance of itself. (or at least it *should* do that.) -class MyConfig(ConfigSpec, default_file="test.toml", writer=TomlWriter, create_file=True): - ... # impl here -``` - -The other option is to just manually load your configuration. - -```python -class MyConfig(ConfigSpec, auto_load=False): - ... - -config = MyConfig.load("test.toml", TomlWriter, create_file=True) # load and/or create our config file -``` - # Installation `pip install comprehensiveconfig` @@ -109,3 +89,24 @@ x = 10 email = "example@email.com" password = "MyPassword" ``` + +# Autoloaded Configuration Classes (Typing Issues in Mypy) + +There is one particular pitfall I have yet to develop around. Mypy is missing a few essential checks that improve type checking on autoloaded configuration classes. For the time being, I recommend avoiding using mypy with this project until class-decorator annotations work [(something that hasn't worked since 2017)](https://github.com/python/mypy/issues/3135) or until I can manage to create a mypy plugin to work around the issue. Pyright doesn't seem to believe anything is wrong. Pyright is the default typechecker used in vscode's pylance extension. + +If type checking issues occur on your autoloaded config classes, try using this temporary decorator as a fix: + +```python +@autoloaded # makes your type checker think `MyConfig` is an instance of itself. (or at least it *should* do that.) +class MyConfig(ConfigSpec, default_file="test.toml", writer=TomlWriter, create_file=True): + ... # impl here +``` + +The other option is to just manually load your configuration. + +```python +class MyConfig(ConfigSpec, auto_load=False): + ... + +config = MyConfig.load("test.toml", TomlWriter, create_file=True) # load and/or create our config file +```