Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{
"mypy.enabled": true
"mypy.enabled": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
}
}
2 changes: 2 additions & 0 deletions comprehensiveconfig/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from . import configio
from . import spec
from . import validators
from .json import JsonWriter
from .toml import TomlWriter

Expand Down Expand Up @@ -147,6 +148,7 @@ def reset_global(cls):
"_ConfigSpecMeta",
"_ConfigSpecABCMeta",
"spec",
"validators",
"configio",
"JsonWriter",
"TomlWriter",
Expand Down
3 changes: 3 additions & 0 deletions comprehensiveconfig/json.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime
import json
from pathlib import Path
from typing import Any
from . import configio
from . import spec
Expand Down Expand Up @@ -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 _:
Expand Down
117 changes: 117 additions & 0 deletions comprehensiveconfig/spec.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -636,6 +645,8 @@ class Text(ConfigurationField):

_holds: str

_regex_pattern: str

def __init__(
self,
default_value: str | _NoDefaultValueT = NoDefaultValue,
Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -848,6 +964,7 @@ def _validate_value(self, value: Any, name: str | None = None, /):
"Table",
"TableSpec",
"List",
"PathField",
"ConfigEnum",
"ConfigObject",
]
3 changes: 2 additions & 1 deletion comprehensiveconfig/toml.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import enum
from pathlib import Path
import tomllib
from typing import Any
from . import configio
Expand Down Expand Up @@ -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)}"'
Expand Down
2 changes: 2 additions & 0 deletions comprehensiveconfig/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
117 changes: 117 additions & 0 deletions comprehensiveconfig/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Additional validators used in more specialty field classes"""

import sys

# unix
NULL_BYTE = "\x00"
# windows
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"""
return NULL_BYTE not in path


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)


__all__ = [
"validate_path_unix",
"validate_path_windows",
"validate_path_agnostic",
"validate_path_sys_aware",
]
Loading
Loading