Source code for persidict.safe_str_tuple

"""Utilities for strict, flat tuples of URL/filename-safe strings.

This module defines SafeStrTuple, an immutable, hashable, flat tuple of non-empty
strings restricted to a predefined safe character set and bounded length. It is
useful for constructing keys and paths that must be portable and safe for URLs
and filesystems.
"""
from __future__ import annotations
from collections.abc import Iterator, Sequence, Mapping, Hashable
from typing import Any, Self
from .safe_chars import SAFE_CHARS_SET, SAFE_STRING_MAX_LENGTH


def _is_sequence_not_mapping(obj: Any) -> bool:
    """Return True if the object looks like a sequence but not a mapping.

    This function prefers ABC checks but falls back to duck-typing to handle
    some custom/typed collections.

    Args:
        obj: Object to inspect.

    Returns:
        True if obj is a sequence (e.g., list, tuple) and not a mapping
        (e.g., dict); otherwise False.
    """
    if isinstance(obj, Sequence) and not isinstance(obj, Mapping):
        return True
    elif hasattr(obj, "keys") and callable(obj.keys):
        return False
    elif (
        hasattr(obj, "__getitem__")
        and callable(obj.__getitem__)
        and hasattr(obj, "__len__")
        and callable(obj.__len__)
        and hasattr(obj, "__iter__")
        and callable(obj.__iter__)
    ):
        return True
    else:
        return False


[docs] class SafeStrTuple(Sequence[str], Hashable): """An immutable sequence of URL/filename-safe strings. The sequence is flat (no nested structures) and hashable, making it suitable for use as a dictionary key. All strings are validated to contain only characters from SAFE_CHARS_SET and to have length less than SAFE_STRING_MAX_LENGTH. """ strings: tuple[str, ...] def __init__(self, *args, **kwargs): """Initialize from strings or nested sequences of strings. The constructor accepts zero or more arguments which may be: - a SafeStrTuple - a single string - a sequence (list/tuple/etc.) containing any of the above recursively The input is flattened left-to-right into a single tuple of validated strings. Empty strings and strings with characters outside SAFE_CHARS_SET are rejected. Strings must also be shorter than SAFE_STRING_MAX_LENGTH. Args: *args: Zero or more inputs (strings, sequences, or SafeStrTuple) that will be flattened into a tuple of safe strings. **kwargs: Not supported. Raises: TypeError: If unexpected keyword arguments are provided or if an argument has an invalid type. ValueError: If a string is empty, too long, contains disallowed characters, or is '.' or '..' (which have special filesystem semantics and are not valid key components). """ if len(kwargs) != 0: raise TypeError(f"Unexpected keyword arguments: {list(kwargs.keys())}") candidate_strings = [] for a in args: if isinstance(a, SafeStrTuple): candidate_strings.extend(a.strings) elif isinstance(a, str): if len(a) == 0: raise ValueError("Strings must be non-empty") if a in (".", ".."): raise ValueError( f"'{a}' is not allowed as a key component") if len(a) >= SAFE_STRING_MAX_LENGTH: raise ValueError( f"String length must be < {SAFE_STRING_MAX_LENGTH}, got {len(a)}") if not all(c in SAFE_CHARS_SET for c in a): raise ValueError("String contains disallowed characters") candidate_strings.append(a) elif _is_sequence_not_mapping(a): if len(a) > 0: candidate_strings.extend(SafeStrTuple(*a).strings) else: raise TypeError(f"Invalid argument type: {type(a)}") self.strings = tuple(candidate_strings) @property def str_chain(self) -> tuple[str, ...]: """Alias for strings for backward compatibility. Returns: The underlying tuple of strings. """ return self.strings def __getitem__(self, key: int) -> str: """Return the string at the given index. Args: key: Zero-based index. Returns: The string at the specified position. """ return self.strings[key] def __len__(self) -> int: """Return the number of strings in the tuple. Returns: The number of elements. """ return len(self.strings) def __hash__(self) -> int: """Compute the hash of the underlying tuple. Returns: A hash value suitable for dict/set usage. """ return hash(self.strings) def __repr__(self) -> str: """Return a developer-friendly representation. Returns: A representation including the class name and contents. """ return f"{type(self).__name__}({self.strings})" def __eq__(self, other) -> bool: """Compare two SafeStrTuple-compatible objects for equality. If other is not a SafeStrTuple, it will be coerced using the same validation rules. If coercion fails, returns NotImplemented to allow Python to try the reverse comparison. Args: other: Another SafeStrTuple or compatible input. Returns: True if both contain the same sequence of strings. """ if isinstance(other, SafeStrTuple): if type(self).__eq__ != type(other).__eq__: return other.__eq__(self) else: try: other = SafeStrTuple(other) except (TypeError, ValueError): return NotImplemented return self.strings == other.strings def __add__(self, other) -> SafeStrTuple: """Concatenate with another SafeStrTuple-compatible object. Args: other: Another SafeStrTuple or compatible input. Returns: A new instance containing elements of self then other. """ other = SafeStrTuple(other) return SafeStrTuple(*(self.strings + other.strings)) def __radd__(self, other) -> SafeStrTuple: """Concatenate with another object in reversed order (other + self). Args: other: Another SafeStrTuple or compatible input. Returns: A new instance containing elements of other then self. """ other = SafeStrTuple(other) return SafeStrTuple(*(other.strings + self.strings)) def __iter__(self) -> Iterator[str]: """Return an iterator over the strings. Returns: An iterator over the internal tuple. """ return iter(self.strings) def __contains__(self, item: object) -> bool: """Check membership. Args: item: String to check for presence. Returns: True if item is present. """ return item in self.strings def __reversed__(self) -> SafeStrTuple: """Return a reversed SafeStrTuple. Returns: A new instance with elements in reverse order. """ return SafeStrTuple(*reversed(self.strings)) def __copy__(self) -> Self: """Return self since immutable objects need no copying.""" return self def __deepcopy__(self, memo: dict[int, Any]) -> Self: """Return self since immutable objects need no deep copying.""" return self
[docs] class NonEmptySafeStrTuple(SafeStrTuple): """A SafeStrTuple that must contain at least one string. This subclass enforces that the tuple is non-empty. """ def __init__(self, *args, **kwargs): """Initialize and enforce non-empty constraint. Args: *args: One or more inputs (strings, sequences, or SafeStrTuple) that will be flattened into a tuple of safe strings. **kwargs: Not supported. Raises: TypeError: If unexpected keyword arguments are provided, if no args are provided, or if an argument has an invalid type. ValueError: If a string is empty, too long, contains disallowed characters, or if the resulting tuple is empty. """ super().__init__(*args, **kwargs) if len(self.strings) == 0: raise ValueError("NonEmptySafeStrTuple must contain at least one string")