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/2] 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/2] 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)