diff --git a/server/libs/attrs-25.3.0.dist-info/REQUESTED b/server/libs/.lock similarity index 100% rename from server/libs/attrs-25.3.0.dist-info/REQUESTED rename to server/libs/.lock diff --git a/server/libs/attr/__init__.pyi b/server/libs/attr/__init__.pyi index 133e501..758decf 100644 --- a/server/libs/attr/__init__.pyi +++ b/server/libs/attr/__init__.pyi @@ -176,7 +176,7 @@ def attrib( type: None = ..., converter: None = ..., factory: None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -200,7 +200,7 @@ def attrib( | tuple[_ConverterType] | None = ..., factory: Callable[[], _T] | None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -223,7 +223,7 @@ def attrib( | tuple[_ConverterType] | None = ..., factory: Callable[[], _T] | None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -246,7 +246,7 @@ def attrib( | tuple[_ConverterType] | None = ..., factory: Callable[[], _T] | None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -308,7 +308,7 @@ def attrs( match_args: bool = ..., unsafe_hash: bool | None = ..., ) -> Callable[[_C], _C]: ... -def fields(cls: type[AttrsInstance]) -> Any: ... +def fields(cls: type[AttrsInstance] | AttrsInstance) -> Any: ... def fields_dict(cls: type[AttrsInstance]) -> dict[str, Attribute[Any]]: ... def validate(inst: AttrsInstance) -> None: ... def resolve_types( diff --git a/server/libs/attr/_compat.py b/server/libs/attr/_compat.py index 22fcd78..bc68ed9 100644 --- a/server/libs/attr/_compat.py +++ b/server/libs/attr/_compat.py @@ -10,7 +10,6 @@ from typing import _GenericAlias PYPY = platform.python_implementation() == "PyPy" -PY_3_9_PLUS = sys.version_info[:2] >= (3, 9) PY_3_10_PLUS = sys.version_info[:2] >= (3, 10) PY_3_11_PLUS = sys.version_info[:2] >= (3, 11) PY_3_12_PLUS = sys.version_info[:2] >= (3, 12) @@ -18,10 +17,16 @@ PY_3_13_PLUS = sys.version_info[:2] >= (3, 13) PY_3_14_PLUS = sys.version_info[:2] >= (3, 14) -if PY_3_14_PLUS: # pragma: no cover +if PY_3_14_PLUS: import annotationlib - _get_annotations = annotationlib.get_annotations + # We request forward-ref annotations to not break in the presence of + # forward references. + + def _get_annotations(cls): + return annotationlib.get_annotations( + cls, format=annotationlib.Format.FORWARDREF + ) else: diff --git a/server/libs/attr/_funcs.py b/server/libs/attr/_funcs.py index c39fb8a..1adb500 100644 --- a/server/libs/attr/_funcs.py +++ b/server/libs/attr/_funcs.py @@ -3,11 +3,28 @@ import copy -from ._compat import PY_3_9_PLUS, get_generic_base +from ._compat import get_generic_base from ._make import _OBJ_SETATTR, NOTHING, fields from .exceptions import AttrsAttributeNotFoundError +_ATOMIC_TYPES = frozenset( + { + type(None), + bool, + int, + float, + str, + complex, + bytes, + type(...), + type, + range, + property, + } +) + + def asdict( inst, recurse=True, @@ -71,7 +88,10 @@ def asdict( v = value_serializer(inst, a, v) if recurse is True: - if has(v.__class__): + value_type = type(v) + if value_type in _ATOMIC_TYPES: + rv[a.name] = v + elif has(value_type): rv[a.name] = asdict( v, recurse=True, @@ -80,8 +100,8 @@ def asdict( retain_collection_types=retain_collection_types, value_serializer=value_serializer, ) - elif isinstance(v, (tuple, list, set, frozenset)): - cf = v.__class__ if retain_collection_types is True else list + elif issubclass(value_type, (tuple, list, set, frozenset)): + cf = value_type if retain_collection_types is True else list items = [ _asdict_anything( i, @@ -101,7 +121,7 @@ def asdict( # Workaround for TypeError: cf.__new__() missing 1 required # positional argument (which appears, for a namedturle) rv[a.name] = cf(*items) - elif isinstance(v, dict): + elif issubclass(value_type, dict): df = dict_factory rv[a.name] = df( ( @@ -142,7 +162,12 @@ def _asdict_anything( """ ``asdict`` only works on attrs instances, this works on anything. """ - if getattr(val.__class__, "__attrs_attrs__", None) is not None: + val_type = type(val) + if val_type in _ATOMIC_TYPES: + rv = val + if value_serializer is not None: + rv = value_serializer(None, None, rv) + elif getattr(val_type, "__attrs_attrs__", None) is not None: # Attrs class. rv = asdict( val, @@ -152,7 +177,7 @@ def _asdict_anything( retain_collection_types=retain_collection_types, value_serializer=value_serializer, ) - elif isinstance(val, (tuple, list, set, frozenset)): + elif issubclass(val_type, (tuple, list, set, frozenset)): if retain_collection_types is True: cf = val.__class__ elif is_key: @@ -173,7 +198,7 @@ def _asdict_anything( for i in val ] ) - elif isinstance(val, dict): + elif issubclass(val_type, dict): df = dict_factory rv = df( ( @@ -253,8 +278,11 @@ def astuple( v = getattr(inst, a.name) if filter is not None and not filter(a, v): continue + value_type = type(v) if recurse is True: - if has(v.__class__): + if value_type in _ATOMIC_TYPES: + rv.append(v) + elif has(value_type): rv.append( astuple( v, @@ -264,7 +292,7 @@ def astuple( retain_collection_types=retain, ) ) - elif isinstance(v, (tuple, list, set, frozenset)): + elif issubclass(value_type, (tuple, list, set, frozenset)): cf = v.__class__ if retain is True else list items = [ ( @@ -288,8 +316,8 @@ def astuple( # Workaround for TypeError: cf.__new__() missing 1 required # positional argument (which appears, for a namedturle) rv.append(cf(*items)) - elif isinstance(v, dict): - df = v.__class__ if retain is True else dict + elif issubclass(value_type, dict): + df = value_type if retain is True else dict rv.append( df( ( @@ -450,10 +478,11 @@ def resolve_types( if getattr(cls, "__attrs_types_resolved__", None) != cls: import typing - kwargs = {"globalns": globalns, "localns": localns} - - if PY_3_9_PLUS: - kwargs["include_extras"] = include_extras + kwargs = { + "globalns": globalns, + "localns": localns, + "include_extras": include_extras, + } hints = typing.get_type_hints(cls, **kwargs) for field in fields(cls) if attribs is None else attribs: diff --git a/server/libs/attr/_make.py b/server/libs/attr/_make.py index e84d979..4b32d6a 100644 --- a/server/libs/attr/_make.py +++ b/server/libs/attr/_make.py @@ -12,6 +12,7 @@ import linecache import sys import types import unicodedata +import weakref from collections.abc import Callable, Mapping from functools import cached_property @@ -113,7 +114,7 @@ def attrib( type=None, converter=None, factory=None, - kw_only=False, + kw_only=None, eq=None, order=None, on_setattr=None, @@ -156,6 +157,9 @@ def attrib( *eq*, *order*, and *cmp* also accept a custom callable .. versionchanged:: 21.1.0 *cmp* undeprecated .. versionadded:: 22.2.0 *alias* + .. versionchanged:: 25.4.0 + *kw_only* can now be None, and its default is also changed from False to + None. """ eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq, order, True @@ -373,7 +377,12 @@ def _collect_base_attrs_broken(cls, taken_attr_names): def _transform_attrs( - cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer + cls, + these, + auto_attribs, + kw_only, + collect_by_mro, + field_transformer, ) -> _Attributes: """ Transform all `_CountingAttr`s on a class into `Attribute`s. @@ -428,8 +437,15 @@ def _transform_attrs( ) fca = Attribute.from_counting_attr + no = ClassProps.KeywordOnly.NO own_attrs = [ - fca(attr_name, ca, anns.get(attr_name)) for attr_name, ca in ca_list + fca( + attr_name, + ca, + kw_only is not no, + anns.get(attr_name), + ) + for attr_name, ca in ca_list ] if collect_by_mro: @@ -441,12 +457,21 @@ def _transform_attrs( cls, {a.name for a in own_attrs} ) - if kw_only: + if kw_only is ClassProps.KeywordOnly.FORCE: own_attrs = [a.evolve(kw_only=True) for a in own_attrs] base_attrs = [a.evolve(kw_only=True) for a in base_attrs] attrs = base_attrs + own_attrs + # Resolve default field alias before executing field_transformer, so that + # the transformer receives fully populated Attribute objects with usable + # alias values. + for a in attrs: + if not a.alias: + # Evolve is very slow, so we hold our nose and do it dirty. + _OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name)) + _OBJ_SETATTR.__get__(a)("alias_is_default", True) + if field_transformer is not None: attrs = tuple(field_transformer(cls, attrs)) @@ -464,13 +489,12 @@ def _transform_attrs( if had_default is False and a.default is not NOTHING: had_default = True - # Resolve default field alias after executing field_transformer. - # This allows field_transformer to differentiate between explicit vs - # default aliases and supply their own defaults. + # Resolve default field alias for any new attributes that the + # field_transformer may have added without setting an alias. for a in attrs: if not a.alias: - # Evolve is very slow, so we hold our nose and do it dirty. _OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name)) + _OBJ_SETATTR.__get__(a)("alias_is_default", True) # Create AttrsClass *after* applying the field_transformer since it may # add or remove attributes! @@ -553,7 +577,7 @@ def _frozen_delattrs(self, name): """ Attached to frozen classes as __delattr__. """ - if isinstance(self, BaseException) and name in ("__notes__",): + if isinstance(self, BaseException) and name == "__notes__": BaseException.__delattr__(self, name) return @@ -651,38 +675,31 @@ class _ClassBuilder: self, cls: type, these, - slots, - frozen, - weakref_slot, - getstate_setstate, - auto_attribs, - kw_only, - cache_hash, - is_exc, - collect_by_mro, - on_setattr, - has_custom_setattr, - field_transformer, + auto_attribs: bool, + props: ClassProps, + has_custom_setattr: bool, ): attrs, base_attrs, base_map = _transform_attrs( cls, these, auto_attribs, - kw_only, - collect_by_mro, - field_transformer, + props.kw_only, + props.collected_fields_by_mro, + props.field_transformer, ) self._cls = cls - self._cls_dict = dict(cls.__dict__) if slots else {} + self._cls_dict = dict(cls.__dict__) if props.is_slotted else {} self._attrs = attrs self._base_names = {a.name for a in base_attrs} self._base_attr_map = base_map self._attr_names = tuple(a.name for a in attrs) - self._slots = slots - self._frozen = frozen - self._weakref_slot = weakref_slot - self._cache_hash = cache_hash + self._slots = props.is_slotted + self._frozen = props.is_frozen + self._weakref_slot = props.has_weakref_slot + self._cache_hash = ( + props.hashability is ClassProps.Hashability.HASHABLE_CACHED + ) self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) self._pre_init_has_args = False if self._has_pre_init: @@ -693,20 +710,21 @@ class _ClassBuilder: self._pre_init_has_args = len(pre_init_signature.parameters) > 1 self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) self._delete_attribs = not bool(these) - self._is_exc = is_exc - self._on_setattr = on_setattr + self._is_exc = props.is_exception + self._on_setattr = props.on_setattr_hook self._has_custom_setattr = has_custom_setattr self._wrote_own_setattr = False self._cls_dict["__attrs_attrs__"] = self._attrs + self._cls_dict["__attrs_props__"] = props - if frozen: + if props.is_frozen: self._cls_dict["__setattr__"] = _frozen_setattrs self._cls_dict["__delattr__"] = _frozen_delattrs self._wrote_own_setattr = True - elif on_setattr in ( + elif self._on_setattr in ( _DEFAULT_ON_SETATTR, setters.validate, setters.convert, @@ -722,18 +740,18 @@ class _ClassBuilder: break if ( ( - on_setattr == _DEFAULT_ON_SETATTR + self._on_setattr == _DEFAULT_ON_SETATTR and not (has_validator or has_converter) ) - or (on_setattr == setters.validate and not has_validator) - or (on_setattr == setters.convert and not has_converter) + or (self._on_setattr == setters.validate and not has_validator) + or (self._on_setattr == setters.convert and not has_converter) ): # If class-level on_setattr is set to convert + validate, but # there's no field to convert or validate, pretend like there's # no on_setattr. self._on_setattr = None - if getstate_setstate: + if props.added_pickling: ( self._cls_dict["__getstate__"], self._cls_dict["__setstate__"], @@ -784,6 +802,7 @@ class _ClassBuilder: self._eval_snippets() if self._slots is True: cls = self._create_slots_class() + self._cls.__attrs_base_of_slotted__ = weakref.ref(cls) else: cls = self._patch_original_class() if PY_3_10_PLUS: @@ -845,6 +864,10 @@ class _ClassBuilder: if k not in (*tuple(self._attr_names), "__dict__", "__weakref__") } + # 3.14.0rc2+ + if hasattr(sys, "_clear_type_descriptors"): + sys._clear_type_descriptors(self._cls) + # If our class doesn't have its own implementation of __setattr__ # (either from the user or by us), check the bases, if one of them has # an attrs-made __setattr__, that needs to be reset. We don't walk the @@ -1081,9 +1104,7 @@ class _ClassBuilder: return self def add_replace(self): - self._cls_dict["__replace__"] = self._add_method_dunders( - lambda self, **changes: evolve(self, **changes) - ) + self._cls_dict["__replace__"] = self._add_method_dunders(evolve) return self def add_match_args(self): @@ -1326,6 +1347,7 @@ def attrs( field_transformer=None, match_args=True, unsafe_hash=None, + force_kw_only=True, ): r""" A class decorator that adds :term:`dunder methods` according to the @@ -1392,6 +1414,10 @@ def attrs( If a class has an *inherited* classmethod called ``__attrs_init_subclass__``, it is executed after the class is created. .. deprecated:: 24.1.0 *hash* is deprecated in favor of *unsafe_hash*. + .. versionchanged:: 25.4.0 + *kw_only* now only applies to attributes defined in the current class, + and respects attribute-level ``kw_only=False`` settings. + .. versionadded:: 25.4.0 *force_kw_only* """ if repr_ns is not None: import warnings @@ -1413,6 +1439,7 @@ def attrs( on_setattr = setters.pipe(*on_setattr) def wrap(cls): + nonlocal hash is_frozen = frozen or _has_frozen_base_class(cls) is_exc = auto_exc is True and issubclass(cls, BaseException) has_own_setattr = auto_detect and _has_own_attribute( @@ -1423,84 +1450,112 @@ def attrs( msg = "Can't freeze a class with a custom __setattr__." raise ValueError(msg) - builder = _ClassBuilder( - cls, - these, - slots, - is_frozen, - weakref_slot, - _determine_whether_to_implement( + eq = not is_exc and _determine_whether_to_implement( + cls, eq_, auto_detect, ("__eq__", "__ne__") + ) + + Hashability = ClassProps.Hashability + + if is_exc: + hashability = Hashability.LEAVE_ALONE + elif hash is True: + hashability = ( + Hashability.HASHABLE_CACHED + if cache_hash + else Hashability.HASHABLE + ) + elif hash is False: + hashability = Hashability.LEAVE_ALONE + elif hash is None: + if auto_detect is True and _has_own_attribute(cls, "__hash__"): + hashability = Hashability.LEAVE_ALONE + elif eq is True and is_frozen is True: + hashability = ( + Hashability.HASHABLE_CACHED + if cache_hash + else Hashability.HASHABLE + ) + elif eq is False: + hashability = Hashability.LEAVE_ALONE + else: + hashability = Hashability.UNHASHABLE + else: + msg = "Invalid value for hash. Must be True, False, or None." + raise TypeError(msg) + + KeywordOnly = ClassProps.KeywordOnly + if kw_only: + kwo = KeywordOnly.FORCE if force_kw_only else KeywordOnly.YES + else: + kwo = KeywordOnly.NO + + props = ClassProps( + is_exception=is_exc, + is_frozen=is_frozen, + is_slotted=slots, + collected_fields_by_mro=collect_by_mro, + added_init=_determine_whether_to_implement( + cls, init, auto_detect, ("__init__",) + ), + added_repr=_determine_whether_to_implement( + cls, repr, auto_detect, ("__repr__",) + ), + added_eq=eq, + added_ordering=not is_exc + and _determine_whether_to_implement( + cls, + order_, + auto_detect, + ("__lt__", "__le__", "__gt__", "__ge__"), + ), + hashability=hashability, + added_match_args=match_args, + kw_only=kwo, + has_weakref_slot=weakref_slot, + added_str=str, + added_pickling=_determine_whether_to_implement( cls, getstate_setstate, auto_detect, ("__getstate__", "__setstate__"), default=slots, ), - auto_attribs, - kw_only, - cache_hash, - is_exc, - collect_by_mro, - on_setattr, - has_own_setattr, - field_transformer, + on_setattr_hook=on_setattr, + field_transformer=field_transformer, ) - if _determine_whether_to_implement( - cls, repr, auto_detect, ("__repr__",) - ): + if not props.is_hashable and cache_hash: + msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." + raise TypeError(msg) + + builder = _ClassBuilder( + cls, + these, + auto_attribs=auto_attribs, + props=props, + has_custom_setattr=has_own_setattr, + ) + + if props.added_repr: builder.add_repr(repr_ns) - if str is True: + if props.added_str: builder.add_str() - eq = _determine_whether_to_implement( - cls, eq_, auto_detect, ("__eq__", "__ne__") - ) - if not is_exc and eq is True: + if props.added_eq: builder.add_eq() - if not is_exc and _determine_whether_to_implement( - cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__") - ): + if props.added_ordering: builder.add_order() if not frozen: builder.add_setattr() - nonlocal hash - if ( - hash is None - and auto_detect is True - and _has_own_attribute(cls, "__hash__") - ): - hash = False - - if hash is not True and hash is not False and hash is not None: - # Can't use `hash in` because 1 == True for example. - msg = "Invalid value for hash. Must be True, False, or None." - raise TypeError(msg) - - if hash is False or (hash is None and eq is False) or is_exc: - # Don't do anything. Should fall back to __object__'s __hash__ - # which is by id. - if cache_hash: - msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." - raise TypeError(msg) - elif hash is True or ( - hash is None and eq is True and is_frozen is True - ): - # Build a __hash__ if told so, or if it's safe. + if props.is_hashable: builder.add_hash() - else: - # Raise TypeError on attempts to hash. - if cache_hash: - msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." - raise TypeError(msg) + elif props.hashability is Hashability.UNHASHABLE: builder.make_unhashable() - if _determine_whether_to_implement( - cls, init, auto_detect, ("__init__",) - ): + if props.added_init: builder.add_init() else: builder.add_attrs_init() @@ -1835,16 +1890,16 @@ def _add_repr(cls, ns=None, attrs=None): def fields(cls): """ - Return the tuple of *attrs* attributes for a class. + Return the tuple of *attrs* attributes for a class or instance. The tuple also allows accessing the fields by their names (see below for examples). Args: - cls (type): Class to introspect. + cls (type): Class or instance to introspect. Raises: - TypeError: If *cls* is not a class. + TypeError: If *cls* is neither a class nor an *attrs* instance. attrs.exceptions.NotAnAttrsClassError: If *cls* is not an *attrs* class. @@ -1855,12 +1910,17 @@ def fields(cls): .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields by name. .. versionchanged:: 23.1.0 Add support for generic classes. + .. versionchanged:: 26.1.0 Add support for instances. """ generic_base = get_generic_base(cls) if generic_base is None and not isinstance(cls, type): - msg = "Passed object must be a class." - raise TypeError(msg) + type_ = type(cls) + if getattr(type_, "__attrs_attrs__", None) is None: + msg = "Passed object must be a class or attrs instance." + raise TypeError(msg) + + return fields(type_) attrs = getattr(cls, "__attrs_attrs__", None) @@ -1967,7 +2027,7 @@ def _make_init_script( attr_dict[a.name] = a if a.on_setattr is not None: - if frozen is True: + if frozen is True and a.on_setattr is not setters.NO_OP: msg = "Frozen classes can't use on_setattr." raise ValueError(msg) @@ -2126,8 +2186,9 @@ def _attrs_to_init_script( ) lines.extend(extra_lines) - args = [] - kw_only_args = [] + args = [] # Parameters in the definition of __init__ + pre_init_args = [] # Parameters in the call to __attrs_pre_init__ + kw_only_args = [] # Used for both 'args' and 'pre_init_args' above attrs_to_validate = [] # This is a dictionary of names to validator and converter callables. @@ -2205,6 +2266,7 @@ def _attrs_to_init_script( kw_only_args.append(arg) else: args.append(arg) + pre_init_args.append(arg_name) if converter is not None: lines.append( @@ -2224,6 +2286,7 @@ def _attrs_to_init_script( kw_only_args.append(arg) else: args.append(arg) + pre_init_args.append(arg_name) lines.append(f"if {arg_name} is not NOTHING:") init_factory_name = _INIT_FACTORY_PAT % (a.name,) @@ -2266,6 +2329,7 @@ def _attrs_to_init_script( kw_only_args.append(arg_name) else: args.append(arg_name) + pre_init_args.append(arg_name) if converter is not None: lines.append( @@ -2322,7 +2386,7 @@ def _attrs_to_init_script( lines.append(f"BaseException.__init__(self, {vals})") args = ", ".join(args) - pre_init_args = args + pre_init_args = ", ".join(pre_init_args) if kw_only_args: # leading comma & kw_only args args += f"{', ' if args else ''}*, {', '.join(kw_only_args)}" @@ -2337,7 +2401,7 @@ def _attrs_to_init_script( pre_init_args += pre_init_kw_only_args if call_pre_init and pre_init_has_args: - # If pre init method has arguments, pass same arguments as `__init__`. + # If pre init method has arguments, pass the values given to __init__. lines[0] = f"self.__attrs_pre_init__({pre_init_args})" # Python <3.12 doesn't allow backslashes in f-strings. @@ -2376,6 +2440,8 @@ class Attribute: - ``name`` (`str`): The name of the attribute. - ``alias`` (`str`): The __init__ parameter name of the attribute, after any explicit overrides and default private-attribute-name handling. + - ``alias_is_default`` (`bool`): Whether the ``alias`` was automatically + generated (``True``) or explicitly provided by the user (``False``). - ``inherited`` (`bool`): Whether or not that attribute has been inherited from a base class. - ``eq_key`` and ``order_key`` (`typing.Callable` or `None`): The @@ -2401,6 +2467,7 @@ class Attribute: equality checks and hashing anymore. .. versionadded:: 21.1.0 *eq_key* and *order_key* .. versionadded:: 22.2.0 *alias* + .. versionadded:: 26.1.0 *alias_is_default* For the full version history of the fields, see `attr.ib`. """ @@ -2425,6 +2492,7 @@ class Attribute: "inherited", "on_setattr", "alias", + "alias_is_default", ) def __init__( @@ -2447,6 +2515,7 @@ class Attribute: order_key=None, on_setattr=None, alias=None, + alias_is_default=None, ): eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq_key or eq, order_key or order, True @@ -2481,12 +2550,20 @@ class Attribute: bound_setattr("inherited", inherited) bound_setattr("on_setattr", on_setattr) bound_setattr("alias", alias) + bound_setattr( + "alias_is_default", + alias is None if alias_is_default is None else alias_is_default, + ) def __setattr__(self, name, value): raise FrozenInstanceError @classmethod - def from_counting_attr(cls, name: str, ca: _CountingAttr, type=None): + def from_counting_attr( + cls, name: str, ca: _CountingAttr, kw_only: bool, type=None + ): + # The 'kw_only' argument is the class-level setting, and is used if the + # attribute itself does not explicitly set 'kw_only'. # type holds the annotated value. deal with conflicts: if type is None: type = ca.type @@ -2505,13 +2582,14 @@ class Attribute: ca.metadata, type, ca.converter, - ca.kw_only, + kw_only if ca.kw_only is None else ca.kw_only, ca.eq, ca.eq_key, ca.order, ca.order_key, ca.on_setattr, ca.alias, + ca.alias is None, ) # Don't use attrs.evolve since fields(Attribute) doesn't work @@ -2530,6 +2608,20 @@ class Attribute: new._setattrs(changes.items()) + if "alias" in changes and "alias_is_default" not in changes: + # Explicit alias provided -- no longer the default. + _OBJ_SETATTR.__get__(new)("alias_is_default", False) + elif ( + "name" in changes + and "alias" not in changes + # Don't auto-generate alias if the user picked picked the old one. + and self.alias_is_default + ): + # Name changed, alias was auto-generated -- update it. + _OBJ_SETATTR.__get__(new)( + "alias", _default_init_alias_for(new.name) + ) + return new # Don't use _add_pickle since fields(Attribute) doesn't work @@ -2546,6 +2638,17 @@ class Attribute: """ Play nice with pickle. """ + if len(state) < len(self.__slots__): + # Pre-26.1.0 pickle without alias_is_default -- infer it + # heuristically. + state_dict = dict(zip(self.__slots__, state)) + alias_is_default = state_dict.get( + "alias" + ) is None or state_dict.get("alias") == _default_init_alias_for( + state_dict["name"] + ) + state = (*state, alias_is_default) + self._setattrs(zip(self.__slots__, state)) def _setattrs(self, name_values_pairs): @@ -2569,7 +2672,7 @@ _a = [ name=name, default=NOTHING, validator=None, - repr=True, + repr=(name != "alias_is_default"), cmp=None, eq=True, order=False, @@ -2741,6 +2844,188 @@ class _CountingAttr: _CountingAttr = _add_eq(_add_repr(_CountingAttr)) +class ClassProps: + """ + Effective class properties as derived from parameters to `attr.s()` or + `define()` decorators. + + This is the same data structure that *attrs* uses internally to decide how + to construct the final class. + + Warning: + + This feature is currently **experimental** and is not covered by our + strict backwards-compatibility guarantees. + + + Attributes: + is_exception (bool): + Whether the class is treated as an exception class. + + is_slotted (bool): + Whether the class is `slotted `. + + has_weakref_slot (bool): + Whether the class has a slot for weak references. + + is_frozen (bool): + Whether the class is frozen. + + kw_only (KeywordOnly): + Whether / how the class enforces keyword-only arguments on the + ``__init__`` method. + + collected_fields_by_mro (bool): + Whether the class fields were collected by method resolution order. + That is, correctly but unlike `dataclasses`. + + added_init (bool): + Whether the class has an *attrs*-generated ``__init__`` method. + + added_repr (bool): + Whether the class has an *attrs*-generated ``__repr__`` method. + + added_eq (bool): + Whether the class has *attrs*-generated equality methods. + + added_ordering (bool): + Whether the class has *attrs*-generated ordering methods. + + hashability (Hashability): How `hashable ` the class is. + + added_match_args (bool): + Whether the class supports positional `match ` over its + fields. + + added_str (bool): + Whether the class has an *attrs*-generated ``__str__`` method. + + added_pickling (bool): + Whether the class has *attrs*-generated ``__getstate__`` and + ``__setstate__`` methods for `pickle`. + + on_setattr_hook (Callable[[Any, Attribute[Any], Any], Any] | None): + The class's ``__setattr__`` hook. + + field_transformer (Callable[[Attribute[Any]], Attribute[Any]] | None): + The class's `field transformers `. + + .. versionadded:: 25.4.0 + """ + + class Hashability(enum.Enum): + """ + The hashability of a class. + + .. versionadded:: 25.4.0 + """ + + HASHABLE = "hashable" + """Write a ``__hash__``.""" + HASHABLE_CACHED = "hashable_cache" + """Write a ``__hash__`` and cache the hash.""" + UNHASHABLE = "unhashable" + """Set ``__hash__`` to ``None``.""" + LEAVE_ALONE = "leave_alone" + """Don't touch ``__hash__``.""" + + class KeywordOnly(enum.Enum): + """ + How attributes should be treated regarding keyword-only parameters. + + .. versionadded:: 25.4.0 + """ + + NO = "no" + """Attributes are not keyword-only.""" + YES = "yes" + """Attributes in current class without kw_only=False are keyword-only.""" + FORCE = "force" + """All attributes are keyword-only.""" + + __slots__ = ( # noqa: RUF023 -- order matters for __init__ + "is_exception", + "is_slotted", + "has_weakref_slot", + "is_frozen", + "kw_only", + "collected_fields_by_mro", + "added_init", + "added_repr", + "added_eq", + "added_ordering", + "hashability", + "added_match_args", + "added_str", + "added_pickling", + "on_setattr_hook", + "field_transformer", + ) + + def __init__( + self, + is_exception, + is_slotted, + has_weakref_slot, + is_frozen, + kw_only, + collected_fields_by_mro, + added_init, + added_repr, + added_eq, + added_ordering, + hashability, + added_match_args, + added_str, + added_pickling, + on_setattr_hook, + field_transformer, + ): + self.is_exception = is_exception + self.is_slotted = is_slotted + self.has_weakref_slot = has_weakref_slot + self.is_frozen = is_frozen + self.kw_only = kw_only + self.collected_fields_by_mro = collected_fields_by_mro + self.added_init = added_init + self.added_repr = added_repr + self.added_eq = added_eq + self.added_ordering = added_ordering + self.hashability = hashability + self.added_match_args = added_match_args + self.added_str = added_str + self.added_pickling = added_pickling + self.on_setattr_hook = on_setattr_hook + self.field_transformer = field_transformer + + @property + def is_hashable(self): + return ( + self.hashability is ClassProps.Hashability.HASHABLE + or self.hashability is ClassProps.Hashability.HASHABLE_CACHED + ) + + +_cas = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=True, + init=True, + inherited=False, + alias=_default_init_alias_for(name), + ) + for name in ClassProps.__slots__ +] + +ClassProps = _add_eq(_add_repr(ClassProps, attrs=_cas), attrs=_cas) + + class Factory: """ Stores a factory callable. @@ -2848,9 +3133,7 @@ class Converter: value, field ) else: - self.__call__ = lambda value, instance, field: self.converter( - value, instance, field - ) + self.__call__ = self.converter rt = ex.get_return_type() if rt is not None: diff --git a/server/libs/attr/_next_gen.py b/server/libs/attr/_next_gen.py index 9290664..4ccd0da 100644 --- a/server/libs/attr/_next_gen.py +++ b/server/libs/attr/_next_gen.py @@ -17,7 +17,7 @@ from ._make import ( attrib, attrs, ) -from .exceptions import UnannotatedAttributeError +from .exceptions import NotAnAttrsClassError, UnannotatedAttributeError def define( @@ -43,6 +43,7 @@ def define( on_setattr=None, field_transformer=None, match_args=True, + force_kw_only=False, ): r""" A class decorator that adds :term:`dunder methods` according to @@ -76,7 +77,7 @@ def define( sensible ``__ne__`` by default, so it *should* be enough to only implement ``__eq__`` in most cases). - Passing True or False` to *init*, *repr*, *eq*, or *hash* + Passing :data:`True` or :data:`False` to *init*, *repr*, *eq*, or *hash* overrides whatever *auto_detect* would determine. auto_exc (bool): @@ -214,8 +215,12 @@ def define( 5. Subclasses of a frozen class are frozen too. kw_only (bool): - Make all attributes keyword-only in the generated ``__init__`` (if - *init* is False, this parameter is ignored). + Make attributes keyword-only in the generated ``__init__`` (if + *init* is False, this parameter is ignored). Attributes that + explicitly set ``kw_only=False`` are not affected; base class + attributes are also not affected. + + Also see *force_kw_only*. weakref_slot (bool): Make instances weak-referenceable. This has no effect unless @@ -244,6 +249,15 @@ def define( See also `issue #428 `_. + force_kw_only (bool): + A back-compat flag for restoring pre-25.4.0 behavior. If True and + ``kw_only=True``, all attributes are made keyword-only, including + base class attributes, and those set to ``kw_only=False`` at the + attribute level. Defaults to False. + + See also `issue #980 + `_. + getstate_setstate (bool | None): .. note:: @@ -319,6 +333,11 @@ def define( .. versionadded:: 24.3.0 Unless already present, a ``__replace__`` method is automatically created for `copy.replace` (Python 3.13+ only). + .. versionchanged:: 25.4.0 + *kw_only* now only applies to attributes defined in the current class, + and respects attribute-level ``kw_only=False`` settings. + .. versionadded:: 25.4.0 + Added *force_kw_only* to go back to the previous *kw_only* behavior. .. note:: @@ -337,6 +356,7 @@ def define( - *auto_exc=True* - *auto_detect=True* - *order=False* + - *force_kw_only=False* - Some options that were only relevant on Python 2 or were kept around for backwards-compatibility have been removed. @@ -366,6 +386,7 @@ def define( on_setattr=on_setattr, field_transformer=field_transformer, match_args=match_args, + force_kw_only=force_kw_only, ) def wrap(cls): @@ -424,7 +445,7 @@ def field( type=None, converter=None, factory=None, - kw_only=False, + kw_only=None, eq=None, order=None, on_setattr=None, @@ -550,9 +571,10 @@ def field( itself. You can use it as part of your own code or for `static type checking `. - kw_only (bool): + kw_only (bool | None): Make this attribute keyword-only in the generated ``__init__`` (if - ``init`` is False, this parameter is ignored). + *init* is False, this parameter is ignored). If None (default), + mirror the setting from `attrs.define`. on_setattr (~typing.Callable | list[~typing.Callable] | None | ~typing.Literal[attrs.setters.NO_OP]): Allows to overwrite the *on_setattr* setting from `attr.s`. If left @@ -572,6 +594,9 @@ def field( .. versionadded:: 23.1.0 The *type* parameter has been re-added; mostly for `attrs.make_class`. Please note that type checkers ignore this metadata. + .. versionchanged:: 25.4.0 + *kw_only* can now be None, and its default is also changed from False to + None. .. seealso:: @@ -621,3 +646,29 @@ def astuple(inst, *, recurse=True, filter=None): return _astuple( inst=inst, recurse=recurse, filter=filter, retain_collection_types=True ) + + +def inspect(cls): + """ + Inspect the class and return its effective build parameters. + + Warning: + This feature is currently **experimental** and is not covered by our + strict backwards-compatibility guarantees. + + Args: + cls: The *attrs*-decorated class to inspect. + + Returns: + The effective build parameters of the class. + + Raises: + NotAnAttrsClassError: If the class is not an *attrs*-decorated class. + + .. versionadded:: 25.4.0 + """ + try: + return cls.__dict__["__attrs_props__"] + except KeyError: + msg = f"{cls!r} is not an attrs-decorated class." + raise NotAnAttrsClassError(msg) from None diff --git a/server/libs/attr/_version_info.py b/server/libs/attr/_version_info.py index 51a1312..27f1888 100644 --- a/server/libs/attr/_version_info.py +++ b/server/libs/attr/_version_info.py @@ -84,3 +84,6 @@ class VersionInfo: # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't # have to do anything special with releaselevel for now. return us < them + + def __hash__(self): + return hash((self.year, self.minor, self.micro, self.releaselevel)) diff --git a/server/libs/attr/exceptions.py b/server/libs/attr/exceptions.py index 3b7abb8..a207df4 100644 --- a/server/libs/attr/exceptions.py +++ b/server/libs/attr/exceptions.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import ClassVar - class FrozenError(AttributeError): """ @@ -16,8 +14,10 @@ class FrozenError(AttributeError): .. versionadded:: 20.1.0 """ - msg = "can't set attribute" - args: ClassVar[tuple[str]] = [msg] + def __init__(self): + msg = "can't set attribute" + super().__init__(msg) + self.msg = msg class FrozenInstanceError(FrozenError): diff --git a/server/libs/attr/validators.py b/server/libs/attr/validators.py index e7b7552..0b1a294 100644 --- a/server/libs/attr/validators.py +++ b/server/libs/attr/validators.py @@ -79,12 +79,14 @@ def disabled(): This context manager is not thread-safe! .. versionadded:: 21.3.0 + .. versionchanged:: 26.1.0 The contextmanager is nestable. """ + prev = get_run_validators() set_run_validators(False) try: yield finally: - set_run_validators(True) + set_run_validators(prev) @attrs(repr=False, slots=True, unsafe_hash=True) @@ -361,26 +363,32 @@ def deep_iterable(member_validator, iterable_validator=None): A validator that performs deep validation of an iterable. Args: - member_validator: Validator to apply to iterable members. + member_validator: Validator(s) to apply to iterable members. iterable_validator: - Validator to apply to iterable itself (optional). + Validator(s) to apply to iterable itself (optional). Raises TypeError: if any sub-validators fail .. versionadded:: 19.1.0 + + .. versionchanged:: 25.4.0 + *member_validator* and *iterable_validator* can now be a list or tuple + of validators. """ if isinstance(member_validator, (list, tuple)): member_validator = and_(*member_validator) + if isinstance(iterable_validator, (list, tuple)): + iterable_validator = and_(*iterable_validator) return _DeepIterable(member_validator, iterable_validator) @attrs(repr=False, slots=True, unsafe_hash=True) class _DeepMapping: - key_validator = attrib(validator=is_callable()) - value_validator = attrib(validator=is_callable()) - mapping_validator = attrib(default=None, validator=optional(is_callable())) + key_validator = attrib(validator=optional(is_callable())) + value_validator = attrib(validator=optional(is_callable())) + mapping_validator = attrib(validator=optional(is_callable())) def __call__(self, inst, attr, value): """ @@ -390,30 +398,62 @@ class _DeepMapping: self.mapping_validator(inst, attr, value) for key in value: - self.key_validator(inst, attr, key) - self.value_validator(inst, attr, value[key]) + if self.key_validator is not None: + self.key_validator(inst, attr, key) + if self.value_validator is not None: + self.value_validator(inst, attr, value[key]) def __repr__(self): return f"" -def deep_mapping(key_validator, value_validator, mapping_validator=None): +def deep_mapping( + key_validator=None, value_validator=None, mapping_validator=None +): """ A validator that performs deep validation of a dictionary. - Args: - key_validator: Validator to apply to dictionary keys. + All validators are optional, but at least one of *key_validator* or + *value_validator* must be provided. - value_validator: Validator to apply to dictionary values. + Args: + key_validator: Validator(s) to apply to dictionary keys. + + value_validator: Validator(s) to apply to dictionary values. mapping_validator: - Validator to apply to top-level mapping attribute (optional). + Validator(s) to apply to top-level mapping attribute. .. versionadded:: 19.1.0 + .. versionchanged:: 25.4.0 + *key_validator* and *value_validator* are now optional, but at least one + of them must be provided. + + .. versionchanged:: 25.4.0 + *key_validator*, *value_validator*, and *mapping_validator* can now be a + list or tuple of validators. + Raises: - TypeError: if any sub-validators fail + TypeError: If any sub-validator fails on validation. + + ValueError: + If neither *key_validator* nor *value_validator* is provided on + instantiation. """ + if key_validator is None and value_validator is None: + msg = ( + "At least one of key_validator or value_validator must be provided" + ) + raise ValueError(msg) + + if isinstance(key_validator, (list, tuple)): + key_validator = and_(*key_validator) + if isinstance(value_validator, (list, tuple)): + value_validator = and_(*value_validator) + if isinstance(mapping_validator, (list, tuple)): + mapping_validator = and_(*mapping_validator) + return _DeepMapping(key_validator, value_validator, mapping_validator) @@ -485,7 +525,7 @@ def gt(val): A validator that raises `ValueError` if the initializer is called with a number smaller or equal to *val*. - The validator uses `operator.ge` to compare the values. + The validator uses `operator.gt` to compare the values. Args: val: Exclusive lower bound for values diff --git a/server/libs/attr/validators.pyi b/server/libs/attr/validators.pyi index a0fdda7..18fb112 100644 --- a/server/libs/attr/validators.pyi +++ b/server/libs/attr/validators.pyi @@ -20,6 +20,9 @@ _T = TypeVar("_T") _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") _T3 = TypeVar("_T3") +_T4 = TypeVar("_T4") +_T5 = TypeVar("_T5") +_T6 = TypeVar("_T6") _I = TypeVar("_I", bound=Iterable) _K = TypeVar("_K") _V = TypeVar("_V") @@ -51,7 +54,7 @@ def optional( validator: ( _ValidatorType[_T] | list[_ValidatorType[_T]] - | tuple[_ValidatorType[_T]] + | tuple[_ValidatorType[_T], ...] ), ) -> _ValidatorType[_T | None]: ... def in_(options: Container[_T]) -> _ValidatorType[_T]: ... @@ -63,12 +66,19 @@ def matches_re( ) -> _ValidatorType[AnyStr]: ... def deep_iterable( member_validator: _ValidatorArgType[_T], - iterable_validator: _ValidatorType[_I] | None = ..., + iterable_validator: _ValidatorArgType[_I] | None = ..., ) -> _ValidatorType[_I]: ... +@overload def deep_mapping( - key_validator: _ValidatorType[_K], - value_validator: _ValidatorType[_V], - mapping_validator: _ValidatorType[_M] | None = ..., + key_validator: _ValidatorArgType[_K], + value_validator: _ValidatorArgType[_V] | None = ..., + mapping_validator: _ValidatorArgType[_M] | None = ..., +) -> _ValidatorType[_M]: ... +@overload +def deep_mapping( + key_validator: _ValidatorArgType[_K] | None = ..., + value_validator: _ValidatorArgType[_V] = ..., + mapping_validator: _ValidatorArgType[_M] | None = ..., ) -> _ValidatorType[_M]: ... def is_callable() -> _ValidatorType[_T]: ... def lt(val: _T) -> _ValidatorType[_T]: ... @@ -83,4 +93,48 @@ def not_( msg: str | None = None, exc_types: type[Exception] | Iterable[type[Exception]] = ..., ) -> _ValidatorType[_T]: ... -def or_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], +) -> _ValidatorType[_T1 | _T2]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], +) -> _ValidatorType[_T1 | _T2 | _T3]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], + __v4: _ValidatorType[_T4], +) -> _ValidatorType[_T1 | _T2 | _T3 | _T4]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], + __v4: _ValidatorType[_T4], + __v5: _ValidatorType[_T5], +) -> _ValidatorType[_T1 | _T2 | _T3 | _T4 | _T5]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], + __v4: _ValidatorType[_T4], + __v5: _ValidatorType[_T5], + __v6: _ValidatorType[_T6], +) -> _ValidatorType[_T1 | _T2 | _T3 | _T4 | _T5 | _T6]: ... +@overload +def or_( + __v1: _ValidatorType[Any], + __v2: _ValidatorType[Any], + __v3: _ValidatorType[Any], + __v4: _ValidatorType[Any], + __v5: _ValidatorType[Any], + __v6: _ValidatorType[Any], + *validators: _ValidatorType[Any], +) -> _ValidatorType[Any]: ... diff --git a/server/libs/attrs-25.3.0.dist-info/INSTALLER b/server/libs/attrs-25.3.0.dist-info/INSTALLER deleted file mode 100644 index a1b589e..0000000 --- a/server/libs/attrs-25.3.0.dist-info/INSTALLER +++ /dev/null @@ -1 +0,0 @@ -pip diff --git a/server/libs/attrs-25.3.0.dist-info/RECORD b/server/libs/attrs-25.3.0.dist-info/RECORD deleted file mode 100644 index 93d392c..0000000 --- a/server/libs/attrs-25.3.0.dist-info/RECORD +++ /dev/null @@ -1,56 +0,0 @@ -attr/__init__.py,sha256=fOYIvt1eGSqQre4uCS3sJWKZ0mwAuC8UD6qba5OS9_U,2057 -attr/__init__.pyi,sha256=QIXnnHPoucmDWkbpNsWTP-cgJ1bn8le7DjyRa_wYdew,11281 -attr/__pycache__/__init__.cpython-311.pyc,, -attr/__pycache__/_cmp.cpython-311.pyc,, -attr/__pycache__/_compat.cpython-311.pyc,, -attr/__pycache__/_config.cpython-311.pyc,, -attr/__pycache__/_funcs.cpython-311.pyc,, -attr/__pycache__/_make.cpython-311.pyc,, -attr/__pycache__/_next_gen.cpython-311.pyc,, -attr/__pycache__/_version_info.cpython-311.pyc,, -attr/__pycache__/converters.cpython-311.pyc,, -attr/__pycache__/exceptions.cpython-311.pyc,, -attr/__pycache__/filters.cpython-311.pyc,, -attr/__pycache__/setters.cpython-311.pyc,, -attr/__pycache__/validators.cpython-311.pyc,, -attr/_cmp.py,sha256=3Nn1TjxllUYiX_nJoVnEkXoDk0hM1DYKj5DE7GZe4i0,4117 -attr/_cmp.pyi,sha256=U-_RU_UZOyPUEQzXE6RMYQQcjkZRY25wTH99sN0s7MM,368 -attr/_compat.py,sha256=4hlXbWhdDjQCDK6FKF1EgnZ3POiHgtpp54qE0nxaGHg,2704 -attr/_config.py,sha256=dGq3xR6fgZEF6UBt_L0T-eUHIB4i43kRmH0P28sJVw8,843 -attr/_funcs.py,sha256=5-tUKJtp3h5El55EcDl6GWXFp68fT8D8U7uCRN6497I,15854 -attr/_make.py,sha256=lBUPPmxiA1BeHzB6OlHoCEh--tVvM1ozXO8eXOa6g4c,96664 -attr/_next_gen.py,sha256=7FRkbtl_N017SuBhf_Vw3mw2c2pGZhtCGOzadgz7tp4,24395 -attr/_typing_compat.pyi,sha256=XDP54TUn-ZKhD62TOQebmzrwFyomhUCoGRpclb6alRA,469 -attr/_version_info.py,sha256=exSqb3b5E-fMSsgZAlEw9XcLpEgobPORCZpcaEglAM4,2121 -attr/_version_info.pyi,sha256=x_M3L3WuB7r_ULXAWjx959udKQ4HLB8l-hsc1FDGNvk,209 -attr/converters.py,sha256=GlDeOzPeTFgeBBLbj9G57Ez5lAk68uhSALRYJ_exe84,3861 -attr/converters.pyi,sha256=orU2bff-VjQa2kMDyvnMQV73oJT2WRyQuw4ZR1ym1bE,643 -attr/exceptions.py,sha256=HRFq4iybmv7-DcZwyjl6M1euM2YeJVK_hFxuaBGAngI,1977 -attr/exceptions.pyi,sha256=zZq8bCUnKAy9mDtBEw42ZhPhAUIHoTKedDQInJD883M,539 -attr/filters.py,sha256=ZBiKWLp3R0LfCZsq7X11pn9WX8NslS2wXM4jsnLOGc8,1795 -attr/filters.pyi,sha256=3J5BG-dTxltBk1_-RuNRUHrv2qu1v8v4aDNAQ7_mifA,208 -attr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -attr/setters.py,sha256=5-dcT63GQK35ONEzSgfXCkbB7pPkaR-qv15mm4PVSzQ,1617 -attr/setters.pyi,sha256=NnVkaFU1BB4JB8E4JuXyrzTUgvtMpj8p3wBdJY7uix4,584 -attr/validators.py,sha256=WaB1HLAHHqRHWsrv_K9H-sJ7ESil3H3Cmv2d8TtVZx4,20046 -attr/validators.pyi,sha256=s2WhKPqskxbsckJfKk8zOuuB088GfgpyxcCYSNFLqNU,2603 -attrs-25.3.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -attrs-25.3.0.dist-info/METADATA,sha256=W38cREj7s1wqNf1fg4hVwZmL1xh0AdSp4IhtTMROinw,10993 -attrs-25.3.0.dist-info/RECORD,, -attrs-25.3.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -attrs-25.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87 -attrs-25.3.0.dist-info/licenses/LICENSE,sha256=iCEVyV38KvHutnFPjsbVy8q_Znyv-HKfQkINpj9xTp8,1109 -attrs/__init__.py,sha256=qeQJZ4O08yczSn840v9bYOaZyRE81WsVi-QCrY3krCU,1107 -attrs/__init__.pyi,sha256=nZmInocjM7tHV4AQw0vxO_fo6oJjL_PonlV9zKKW8DY,7931 -attrs/__pycache__/__init__.cpython-311.pyc,, -attrs/__pycache__/converters.cpython-311.pyc,, -attrs/__pycache__/exceptions.cpython-311.pyc,, -attrs/__pycache__/filters.cpython-311.pyc,, -attrs/__pycache__/setters.cpython-311.pyc,, -attrs/__pycache__/validators.cpython-311.pyc,, -attrs/converters.py,sha256=8kQljrVwfSTRu8INwEk8SI0eGrzmWftsT7rM0EqyohM,76 -attrs/exceptions.py,sha256=ACCCmg19-vDFaDPY9vFl199SPXCQMN_bENs4DALjzms,76 -attrs/filters.py,sha256=VOUMZug9uEU6dUuA0dF1jInUK0PL3fLgP0VBS5d-CDE,73 -attrs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -attrs/setters.py,sha256=eL1YidYQV3T2h9_SYIZSZR1FAcHGb1TuCTy0E0Lv2SU,73 -attrs/validators.py,sha256=xcy6wD5TtTkdCG1f4XWbocPSO0faBjk5IfVJfP6SUj0,76 diff --git a/server/libs/attrs-26.1.0.dist-info/INSTALLER b/server/libs/attrs-26.1.0.dist-info/INSTALLER new file mode 100644 index 0000000..5c69047 --- /dev/null +++ b/server/libs/attrs-26.1.0.dist-info/INSTALLER @@ -0,0 +1 @@ +uv \ No newline at end of file diff --git a/server/libs/attrs-25.3.0.dist-info/METADATA b/server/libs/attrs-26.1.0.dist-info/METADATA similarity index 61% rename from server/libs/attrs-25.3.0.dist-info/METADATA rename to server/libs/attrs-26.1.0.dist-info/METADATA index 029afee..5cf16a0 100644 --- a/server/libs/attrs-25.3.0.dist-info/METADATA +++ b/server/libs/attrs-26.1.0.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: attrs -Version: 25.3.0 +Version: 26.1.0 Summary: Classes Without Boilerplate Project-URL: Documentation, https://www.attrs.org/ Project-URL: Changelog, https://www.attrs.org/en/stable/changelog.html @@ -12,62 +12,16 @@ License-Expression: MIT License-File: LICENSE Keywords: attribute,boilerplate,class Classifier: Development Status :: 5 - Production/Stable -Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Typing :: Typed -Requires-Python: >=3.8 -Provides-Extra: benchmark -Requires-Dist: cloudpickle; (platform_python_implementation == 'CPython') and extra == 'benchmark' -Requires-Dist: hypothesis; extra == 'benchmark' -Requires-Dist: mypy>=1.11.1; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'benchmark' -Requires-Dist: pympler; extra == 'benchmark' -Requires-Dist: pytest-codspeed; extra == 'benchmark' -Requires-Dist: pytest-mypy-plugins; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'benchmark' -Requires-Dist: pytest-xdist[psutil]; extra == 'benchmark' -Requires-Dist: pytest>=4.3.0; extra == 'benchmark' -Provides-Extra: cov -Requires-Dist: cloudpickle; (platform_python_implementation == 'CPython') and extra == 'cov' -Requires-Dist: coverage[toml]>=5.3; extra == 'cov' -Requires-Dist: hypothesis; extra == 'cov' -Requires-Dist: mypy>=1.11.1; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'cov' -Requires-Dist: pympler; extra == 'cov' -Requires-Dist: pytest-mypy-plugins; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'cov' -Requires-Dist: pytest-xdist[psutil]; extra == 'cov' -Requires-Dist: pytest>=4.3.0; extra == 'cov' -Provides-Extra: dev -Requires-Dist: cloudpickle; (platform_python_implementation == 'CPython') and extra == 'dev' -Requires-Dist: hypothesis; extra == 'dev' -Requires-Dist: mypy>=1.11.1; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'dev' -Requires-Dist: pre-commit-uv; extra == 'dev' -Requires-Dist: pympler; extra == 'dev' -Requires-Dist: pytest-mypy-plugins; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'dev' -Requires-Dist: pytest-xdist[psutil]; extra == 'dev' -Requires-Dist: pytest>=4.3.0; extra == 'dev' -Provides-Extra: docs -Requires-Dist: cogapp; extra == 'docs' -Requires-Dist: furo; extra == 'docs' -Requires-Dist: myst-parser; extra == 'docs' -Requires-Dist: sphinx; extra == 'docs' -Requires-Dist: sphinx-notfound-page; extra == 'docs' -Requires-Dist: sphinxcontrib-towncrier; extra == 'docs' -Requires-Dist: towncrier; extra == 'docs' -Provides-Extra: tests -Requires-Dist: cloudpickle; (platform_python_implementation == 'CPython') and extra == 'tests' -Requires-Dist: hypothesis; extra == 'tests' -Requires-Dist: mypy>=1.11.1; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'tests' -Requires-Dist: pympler; extra == 'tests' -Requires-Dist: pytest-mypy-plugins; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'tests' -Requires-Dist: pytest-xdist[psutil]; extra == 'tests' -Requires-Dist: pytest>=4.3.0; extra == 'tests' -Provides-Extra: tests-mypy -Requires-Dist: mypy>=1.11.1; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'tests-mypy' -Requires-Dist: pytest-mypy-plugins; (platform_python_implementation == 'CPython' and python_version >= '3.10') and extra == 'tests-mypy' +Requires-Python: >=3.9 Description-Content-Type: text/markdown

@@ -78,7 +32,7 @@ Description-Content-Type: text/markdown *attrs* is the Python package that will bring back the **joy** of **writing classes** by relieving you from the drudgery of implementing object protocols (aka [dunder methods](https://www.attrs.org/en/latest/glossary.html#term-dunder-methods)). -[Trusted by NASA](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/personalizing-your-profile#list-of-qualifying-repositories-for-mars-2020-helicopter-contributor-achievement) for Mars missions since 2020! +Trusted by NASA for [Mars missions since 2020](https://github.com/readme/featured/nasa-ingenuity-helicopter)! Its main goal is to help you to write **concise** and **correct** software without slowing down your code. @@ -96,15 +50,15 @@ Especially those generously supporting us at the *The Organization* tier and hig import pathlib, tomllib for sponsor in tomllib.loads(pathlib.Path("pyproject.toml").read_text())["tool"]["sponcon"]["sponsors"]: - print(f'') + print(f'') ]]] --> - - - - - - - + + + + + + +

@@ -220,10 +174,23 @@ Save time, reduce risk, and improve code health, while paying the maintainers of ## Release Information +### Backwards-incompatible Changes + +- Field aliases are now resolved *before* calling `field_transformer`, so transformers receive fully populated `Attribute` objects with usable `alias` values instead of `None`. + The new `Attribute.alias_is_default` flag indicates whether the alias was auto-generated (`True`) or explicitly set by the user (`False`). + [#1509](https://github.com/python-attrs/attrs/issues/1509) + + ### Changes -- Restore support for generator-based `field_transformer`s. - [#1417](https://github.com/python-attrs/attrs/issues/1417) +- Fix type annotations for `attrs.validators.optional()`, so it no longer rejects tuples with more than one validator. + [#1496](https://github.com/python-attrs/attrs/issues/1496) +- The `attrs.validators.disabled()` contextmanager can now be nested. + [#1513](https://github.com/python-attrs/attrs/issues/1513) +- Frozen classes can set `on_setattr=attrs.setters.NO_OP` in addition to `None`. + [#1515](https://github.com/python-attrs/attrs/issues/1515) +- It's now possible to pass *attrs* **instances** in addition to *attrs* **classes** to `attrs.fields()`. + [#1529](https://github.com/python-attrs/attrs/issues/1529) diff --git a/server/libs/attrs-26.1.0.dist-info/RECORD b/server/libs/attrs-26.1.0.dist-info/RECORD new file mode 100644 index 0000000..684f95b --- /dev/null +++ b/server/libs/attrs-26.1.0.dist-info/RECORD @@ -0,0 +1,37 @@ +attr/__init__.py,sha256=fOYIvt1eGSqQre4uCS3sJWKZ0mwAuC8UD6qba5OS9_U,2057 +attr/__init__.pyi,sha256=pVGImAUVovq2_TYl_r_HIYnGlyOaoCuEhxo-EvsnnSc,11325 +attr/_cmp.py,sha256=3Nn1TjxllUYiX_nJoVnEkXoDk0hM1DYKj5DE7GZe4i0,4117 +attr/_cmp.pyi,sha256=U-_RU_UZOyPUEQzXE6RMYQQcjkZRY25wTH99sN0s7MM,368 +attr/_compat.py,sha256=x0g7iEUOnBVJC72zyFCgb1eKqyxS-7f2LGnNyZ_r95s,2829 +attr/_config.py,sha256=dGq3xR6fgZEF6UBt_L0T-eUHIB4i43kRmH0P28sJVw8,843 +attr/_funcs.py,sha256=Ix5IETTfz5F01F-12MF_CSFomIn2h8b67EVVz2gCtBE,16479 +attr/_make.py,sha256=H7OH2eWS5CnBzLUjNFE1WymfPrmF1r8fv2RPdt9MuYA,106129 +attr/_next_gen.py,sha256=BQtCUlzwg2gWHTYXBQvrEYBnzBUrDvO57u0Py6UCPhc,26274 +attr/_typing_compat.pyi,sha256=XDP54TUn-ZKhD62TOQebmzrwFyomhUCoGRpclb6alRA,469 +attr/_version_info.py,sha256=w4R-FYC3NK_kMkGUWJlYP4cVAlH9HRaC-um3fcjYkHM,2222 +attr/_version_info.pyi,sha256=x_M3L3WuB7r_ULXAWjx959udKQ4HLB8l-hsc1FDGNvk,209 +attr/converters.py,sha256=GlDeOzPeTFgeBBLbj9G57Ez5lAk68uhSALRYJ_exe84,3861 +attr/converters.pyi,sha256=orU2bff-VjQa2kMDyvnMQV73oJT2WRyQuw4ZR1ym1bE,643 +attr/exceptions.py,sha256=b4vMbnoQ3VpwWZhqrYi_ssXVCK8o2c4HQSS09cSUM9o,1990 +attr/exceptions.pyi,sha256=zZq8bCUnKAy9mDtBEw42ZhPhAUIHoTKedDQInJD883M,539 +attr/filters.py,sha256=ZBiKWLp3R0LfCZsq7X11pn9WX8NslS2wXM4jsnLOGc8,1795 +attr/filters.pyi,sha256=3J5BG-dTxltBk1_-RuNRUHrv2qu1v8v4aDNAQ7_mifA,208 +attr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +attr/setters.py,sha256=5-dcT63GQK35ONEzSgfXCkbB7pPkaR-qv15mm4PVSzQ,1617 +attr/setters.pyi,sha256=NnVkaFU1BB4JB8E4JuXyrzTUgvtMpj8p3wBdJY7uix4,584 +attr/validators.py,sha256=m3QRzZTANr4f2C4eVdUoFg11NgXWak8Wat4qQTGhvcs,21553 +attr/validators.pyi,sha256=gM1ZmHaBckyYWI2EirpRNzqm3B19cw5Iq6B4Kno9YCM,4087 +attrs-26.1.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2 +attrs-26.1.0.dist-info/METADATA,sha256=TNQOaQ8jvzfLytNO_WdY4GLfHfB8hoM_fjzpW_H6OMw,8754 +attrs-26.1.0.dist-info/RECORD,, +attrs-26.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +attrs-26.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87 +attrs-26.1.0.dist-info/licenses/LICENSE,sha256=iCEVyV38KvHutnFPjsbVy8q_Znyv-HKfQkINpj9xTp8,1109 +attrs/__init__.py,sha256=RxaAZNwYiEh-fcvHLZNpQ_DWKni73M_jxEPEftiq1Zc,1183 +attrs/__init__.pyi,sha256=2gV79g9UxJppGSM48hAZJ6h_MHb70dZoJL31ZNJeZYI,9416 +attrs/converters.py,sha256=8kQljrVwfSTRu8INwEk8SI0eGrzmWftsT7rM0EqyohM,76 +attrs/exceptions.py,sha256=ACCCmg19-vDFaDPY9vFl199SPXCQMN_bENs4DALjzms,76 +attrs/filters.py,sha256=VOUMZug9uEU6dUuA0dF1jInUK0PL3fLgP0VBS5d-CDE,73 +attrs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +attrs/setters.py,sha256=eL1YidYQV3T2h9_SYIZSZR1FAcHGb1TuCTy0E0Lv2SU,73 +attrs/validators.py,sha256=xcy6wD5TtTkdCG1f4XWbocPSO0faBjk5IfVJfP6SUj0,76 diff --git a/server/libs/cattrs-25.1.1.dist-info/REQUESTED b/server/libs/attrs-26.1.0.dist-info/REQUESTED similarity index 100% rename from server/libs/cattrs-25.1.1.dist-info/REQUESTED rename to server/libs/attrs-26.1.0.dist-info/REQUESTED diff --git a/server/libs/cattrs-25.1.1.dist-info/WHEEL b/server/libs/attrs-26.1.0.dist-info/WHEEL similarity index 67% rename from server/libs/cattrs-25.1.1.dist-info/WHEEL rename to server/libs/attrs-26.1.0.dist-info/WHEEL index 12228d4..b1b94fd 100644 --- a/server/libs/cattrs-25.1.1.dist-info/WHEEL +++ b/server/libs/attrs-26.1.0.dist-info/WHEEL @@ -1,4 +1,4 @@ Wheel-Version: 1.0 -Generator: hatchling 1.27.0 +Generator: hatchling 1.29.0 Root-Is-Purelib: true Tag: py3-none-any diff --git a/server/libs/attrs-25.3.0.dist-info/licenses/LICENSE b/server/libs/attrs-26.1.0.dist-info/licenses/LICENSE similarity index 100% rename from server/libs/attrs-25.3.0.dist-info/licenses/LICENSE rename to server/libs/attrs-26.1.0.dist-info/licenses/LICENSE diff --git a/server/libs/attrs/__init__.py b/server/libs/attrs/__init__.py index e8023ff..dc1ce4b 100644 --- a/server/libs/attrs/__init__.py +++ b/server/libs/attrs/__init__.py @@ -22,7 +22,8 @@ from attr import ( resolve_types, validate, ) -from attr._next_gen import asdict, astuple +from attr._make import ClassProps +from attr._next_gen import asdict, astuple, inspect from . import converters, exceptions, filters, setters, validators @@ -31,6 +32,7 @@ __all__ = [ "NOTHING", "Attribute", "AttrsInstance", + "ClassProps", "Converter", "Factory", "NothingType", @@ -58,6 +60,7 @@ __all__ = [ "filters", "frozen", "has", + "inspect", "make_class", "mutable", "resolve_types", diff --git a/server/libs/attrs/__init__.pyi b/server/libs/attrs/__init__.pyi index 648fa7a..6364bac 100644 --- a/server/libs/attrs/__init__.pyi +++ b/server/libs/attrs/__init__.pyi @@ -77,7 +77,7 @@ def field( metadata: Mapping[Any, Any] | None = ..., converter: None = ..., factory: None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: bool | None = ..., order: bool | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -98,10 +98,10 @@ def field( metadata: Mapping[Any, Any] | None = ..., converter: _ConverterType | list[_ConverterType] - | tuple[_ConverterType] + | tuple[_ConverterType, ...] | None = ..., factory: Callable[[], _T] | None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -121,10 +121,10 @@ def field( metadata: Mapping[Any, Any] | None = ..., converter: _ConverterType | list[_ConverterType] - | tuple[_ConverterType] + | tuple[_ConverterType, ...] | None = ..., factory: Callable[[], _T] | None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -144,10 +144,10 @@ def field( metadata: Mapping[Any, Any] | None = ..., converter: _ConverterType | list[_ConverterType] - | tuple[_ConverterType] + | tuple[_ConverterType, ...] | None = ..., factory: Callable[[], _T] | None = ..., - kw_only: bool = ..., + kw_only: bool | None = ..., eq: _EqOrderType | None = ..., order: _EqOrderType | None = ..., on_setattr: _OnSetAttrArgType | None = ..., @@ -261,3 +261,54 @@ def frozen( field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., ) -> Callable[[_C], _C]: ... + +class ClassProps: + # XXX: somehow when defining/using enums Mypy starts looking at our own + # (untyped) code and causes tons of errors. + Hashability: Any + KeywordOnly: Any + + is_exception: bool + is_slotted: bool + has_weakref_slot: bool + is_frozen: bool + # kw_only: ClassProps.KeywordOnly + kw_only: Any + collected_fields_by_mro: bool + added_init: bool + added_repr: bool + added_eq: bool + added_ordering: bool + # hashability: ClassProps.Hashability + hashability: Any + added_match_args: bool + added_str: bool + added_pickling: bool + on_setattr_hook: _OnSetAttrType | None + field_transformer: Callable[[Attribute[Any]], Attribute[Any]] | None + + def __init__( + self, + is_exception: bool, + is_slotted: bool, + has_weakref_slot: bool, + is_frozen: bool, + # kw_only: ClassProps.KeywordOnly + kw_only: Any, + collected_fields_by_mro: bool, + added_init: bool, + added_repr: bool, + added_eq: bool, + added_ordering: bool, + # hashability: ClassProps.Hashability + hashability: Any, + added_match_args: bool, + added_str: bool, + added_pickling: bool, + on_setattr_hook: _OnSetAttrType, + field_transformer: Callable[[Attribute[Any]], Attribute[Any]], + ) -> None: ... + @property + def is_hashable(self) -> bool: ... + +def inspect(cls: type) -> ClassProps: ... diff --git a/server/libs/bin/tclfmt.exe b/server/libs/bin/tclfmt.exe index 3173b08..6f9a9d3 100644 Binary files a/server/libs/bin/tclfmt.exe and b/server/libs/bin/tclfmt.exe differ diff --git a/server/libs/bin/tclint.exe b/server/libs/bin/tclint.exe index 6585f69..0e31611 100644 Binary files a/server/libs/bin/tclint.exe and b/server/libs/bin/tclint.exe differ diff --git a/server/libs/bin/tclsp.exe b/server/libs/bin/tclsp.exe index 8fbb54d..770e249 100644 Binary files a/server/libs/bin/tclsp.exe and b/server/libs/bin/tclsp.exe differ diff --git a/server/libs/cattrs-25.1.1.dist-info/INSTALLER b/server/libs/cattrs-25.1.1.dist-info/INSTALLER deleted file mode 100644 index a1b589e..0000000 --- a/server/libs/cattrs-25.1.1.dist-info/INSTALLER +++ /dev/null @@ -1 +0,0 @@ -pip diff --git a/server/libs/cattrs-25.1.1.dist-info/RECORD b/server/libs/cattrs-25.1.1.dist-info/RECORD deleted file mode 100644 index 3b261d8..0000000 --- a/server/libs/cattrs-25.1.1.dist-info/RECORD +++ /dev/null @@ -1,102 +0,0 @@ -cattr/__init__.py,sha256=bYrmwTYSdYC_ut1xW31V7mxhXBlJQKs8EECgtUBgAuc,906 -cattr/__pycache__/__init__.cpython-311.pyc,, -cattr/__pycache__/converters.cpython-311.pyc,, -cattr/__pycache__/disambiguators.cpython-311.pyc,, -cattr/__pycache__/dispatch.cpython-311.pyc,, -cattr/__pycache__/errors.cpython-311.pyc,, -cattr/__pycache__/gen.cpython-311.pyc,, -cattr/converters.py,sha256=rQhY4J8r7QTZh5WICuFe4GWO1v0DS3DgQ9r569zd6jg,192 -cattr/disambiguators.py,sha256=ugD1fq1Z5x1pGu5P1lMzcT-IEi1q7IfQJIHEdmg62vM,103 -cattr/dispatch.py,sha256=uVEOgHWR9Hn5tm-wIw-bDccqrxJByVi8yRKaYyvL67k,125 -cattr/errors.py,sha256=V4RhoCObwGrlaM3oyn1H_FYxGR8iAB9dG5NxFDYM548,343 -cattr/gen.py,sha256=hWyKoZ_d2D36Jz_npspyGw8s9pWtUA69sXf0R3uOvgM,597 -cattr/preconf/__init__.py,sha256=NqPE7uhVfcP-PggkUpsbfAutMo8oHjcoB1cvjgLft-s,78 -cattr/preconf/__pycache__/__init__.cpython-311.pyc,, -cattr/preconf/__pycache__/bson.cpython-311.pyc,, -cattr/preconf/__pycache__/json.cpython-311.pyc,, -cattr/preconf/__pycache__/msgpack.cpython-311.pyc,, -cattr/preconf/__pycache__/orjson.cpython-311.pyc,, -cattr/preconf/__pycache__/pyyaml.cpython-311.pyc,, -cattr/preconf/__pycache__/tomlkit.cpython-311.pyc,, -cattr/preconf/__pycache__/ujson.cpython-311.pyc,, -cattr/preconf/bson.py,sha256=Bn4hJxac7OthGg_CR4LCPeBp_fz4kx3QniBVOZhguGs,195 -cattr/preconf/json.py,sha256=LpqYuO3oePDxbQtKFKB0SaoeAi3Z_agIgyNn1VQSIVo,206 -cattr/preconf/msgpack.py,sha256=pyJ9L9ekNlZ0IQHbJ9Ay_fi_NOqY5_rE_q-UnD94-RM,207 -cattr/preconf/orjson.py,sha256=Adh-7csx4eqCjx22zipMFgSlDXbR554wvgNHEb8Q5JM,203 -cattr/preconf/pyyaml.py,sha256=Fy40bejjp7uqgoLhTA_p4wZYF0uFaguHbUK9zs9LoC0,203 -cattr/preconf/tomlkit.py,sha256=_gADJ_UYpj3EiNXGYjAfSOkcoFIkLpYVOFfLEqBfIJQ,207 -cattr/preconf/ujson.py,sha256=IzEa7QUcYOaSUMiLQsFEWJnBihmmOLhehsM-5cPY9NI,199 -cattr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -cattrs-25.1.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -cattrs-25.1.1.dist-info/METADATA,sha256=ODqSak3dhIZZjmFa-SZT8Si32_3ey_oo2tUefYx0QtU,8388 -cattrs-25.1.1.dist-info/RECORD,, -cattrs-25.1.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -cattrs-25.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87 -cattrs-25.1.1.dist-info/licenses/LICENSE,sha256=9fudHt43qIykf0IMSZ3KD0oFvJk-Esd9I1IKrSkcAb8,1074 -cattrs/__init__.py,sha256=UhiFdxf81gCuBBA6FutoE1oOzthzF_PkAdoE2AVslIo,1901 -cattrs/__pycache__/__init__.cpython-311.pyc,, -cattrs/__pycache__/_compat.cpython-311.pyc,, -cattrs/__pycache__/_generics.cpython-311.pyc,, -cattrs/__pycache__/cols.cpython-311.pyc,, -cattrs/__pycache__/converters.cpython-311.pyc,, -cattrs/__pycache__/disambiguators.cpython-311.pyc,, -cattrs/__pycache__/dispatch.cpython-311.pyc,, -cattrs/__pycache__/errors.cpython-311.pyc,, -cattrs/__pycache__/fns.cpython-311.pyc,, -cattrs/__pycache__/literals.cpython-311.pyc,, -cattrs/__pycache__/typealiases.cpython-311.pyc,, -cattrs/__pycache__/types.cpython-311.pyc,, -cattrs/__pycache__/v.cpython-311.pyc,, -cattrs/_compat.py,sha256=dMRB8a8RkxFdnQDKRpamresVF_SBkksM2_ZMAuL0s2w,11987 -cattrs/_generics.py,sha256=keExDE2CGIer8ci12SoJ_rXYTLva9P29uLlvyb_fxtM,966 -cattrs/cols.py,sha256=mWDchfvjMQ6uACKSfdZs05YiXrWdph2HOJfaqY3D_EI,8848 -cattrs/converters.py,sha256=ui4BSAxnV1J6Oh0pYN2oOHgp-mIybsXW-M8SXPDNzlo,54262 -cattrs/disambiguators.py,sha256=eUyWMtW6bQJcXGOWiraq1pFcMlUbo9BJHxFY--KF6Lk,6867 -cattrs/dispatch.py,sha256=9qA-pmsPvgrM6MGP8Ev2gVP6YL2rXvoBla_C0VgHxQ0,6780 -cattrs/errors.py,sha256=6IGfE-wVQbOaDNN4xAQJf7Hk_t2QNV0Zem64D6yMZrU,4168 -cattrs/fns.py,sha256=z5z1VZOZv8t5LwG8cBM_tIXg-_PlQUOyZb9wIrXNqlw,626 -cattrs/gen/__init__.py,sha256=bpRGHd3G0UTpWcHRcJTNAUcWmX-evoPHGvpH6u29FDU,38842 -cattrs/gen/__pycache__/__init__.cpython-311.pyc,, -cattrs/gen/__pycache__/_consts.cpython-311.pyc,, -cattrs/gen/__pycache__/_generics.cpython-311.pyc,, -cattrs/gen/__pycache__/_lc.cpython-311.pyc,, -cattrs/gen/__pycache__/_shared.cpython-311.pyc,, -cattrs/gen/__pycache__/typeddicts.cpython-311.pyc,, -cattrs/gen/_consts.py,sha256=ZwT_m2J3S7p-UjltpbA1WtfQZLNj9KhmFYCAv6Zl-g0,511 -cattrs/gen/_generics.py,sha256=_DyXCGql2QIxGhAv3_B1hsi80uPK8PhK2hhZa95YOlo,3011 -cattrs/gen/_lc.py,sha256=4fjeUsmgQcCAIjnNndBic0gf5qKmxVS3CZHqUQ9Rw5g,882 -cattrs/gen/_shared.py,sha256=xKsfcVtpyYIir9AW8VuOVoiSbaEI7tsSL0JpUCIUX-g,2296 -cattrs/gen/typeddicts.py,sha256=Ck3QMr_B1T7vwxyRjfZPHafKphN2hndL181dpQNxzPs,21254 -cattrs/literals.py,sha256=0kzAewmWk9ikJGoKq4ysnAR22DMawG3iNqLl8NLgpk0,331 -cattrs/preconf/__init__.py,sha256=P7czFRcjeN6zBcdwUyeBloniltlJptCa8Yd2uFGlz9w,1527 -cattrs/preconf/__pycache__/__init__.cpython-311.pyc,, -cattrs/preconf/__pycache__/bson.cpython-311.pyc,, -cattrs/preconf/__pycache__/cbor2.cpython-311.pyc,, -cattrs/preconf/__pycache__/json.cpython-311.pyc,, -cattrs/preconf/__pycache__/msgpack.cpython-311.pyc,, -cattrs/preconf/__pycache__/msgspec.cpython-311.pyc,, -cattrs/preconf/__pycache__/orjson.cpython-311.pyc,, -cattrs/preconf/__pycache__/pyyaml.cpython-311.pyc,, -cattrs/preconf/__pycache__/tomlkit.cpython-311.pyc,, -cattrs/preconf/__pycache__/ujson.cpython-311.pyc,, -cattrs/preconf/bson.py,sha256=6p1kmOFMjswSXFCb1hKJeNvr3kNsAm1gfX_DA6igq8E,4201 -cattrs/preconf/cbor2.py,sha256=LnREcjpOp_402poUGRVIhDWI4f_R1wvJkdKvs5MrTGU,2022 -cattrs/preconf/json.py,sha256=zTrkfjOxXZFwwabNESeafy-C7MEpn7Cw5AdhkkeOjU4,2631 -cattrs/preconf/msgpack.py,sha256=dZE9tsAA5qX3pSc3MZmlGuvJ5q_wI6mANDyugKXKj-E,2325 -cattrs/preconf/msgspec.py,sha256=Ds0rPW4900zsBLqfupF-smkg9_Kwyx7D_Vh9a0yJB8M,7250 -cattrs/preconf/orjson.py,sha256=5MBcUsyp3eGsHgLfLtt8-q90L2mxjD0ttnrWBUIwouo,3870 -cattrs/preconf/pyyaml.py,sha256=w0aM_gJ6VhZf-Zpu_UlJki7rdgv4mfaSXElPofB3nlE,2378 -cattrs/preconf/tomlkit.py,sha256=gJWGJjMONCViTMZuphOg2xXzjQt3SCEVFVdoKgDjqc8,3148 -cattrs/preconf/ujson.py,sha256=wRLidBM8aWucFkCQ9haiktY8xYoCdanDhQuKJLQJgGM,2425 -cattrs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -cattrs/strategies/__init__.py,sha256=nkZWCzSRYcS-75FMfk52mioZSuWykaN8hB39Vig5Xkg,339 -cattrs/strategies/__pycache__/__init__.cpython-311.pyc,, -cattrs/strategies/__pycache__/_class_methods.cpython-311.pyc,, -cattrs/strategies/__pycache__/_subclasses.cpython-311.pyc,, -cattrs/strategies/__pycache__/_unions.cpython-311.pyc,, -cattrs/strategies/_class_methods.py,sha256=O5xhQCzNpuFiDNDMlbcyeOVqyrV65NhMZNRsG3jnoBU,2591 -cattrs/strategies/_subclasses.py,sha256=aCE2UQjevZQHMnPOPyl2qR_hgRpgRUt1j9lE4qZ3hNc,9365 -cattrs/strategies/_unions.py,sha256=YBBklVSWJ-7DSkLDLpumwAJJ39ALuSGyB6W0Ptz5Rz4,9355 -cattrs/typealiases.py,sha256=toHavC2kJsIcxThwvATPO5JShzKeC8kIl9KqteFohbw,1619 -cattrs/types.py,sha256=cqvfmzliYfrvPswxlW_tN4DmhQ2xpAKQvVbNBJaxiWs,278 -cattrs/v.py,sha256=IqUajgJFCKJYf-4S9TCKRtJcmmK4c3En69TGuf2FKOs,4126 diff --git a/server/libs/cattrs-26.1.0.dist-info/INSTALLER b/server/libs/cattrs-26.1.0.dist-info/INSTALLER new file mode 100644 index 0000000..5c69047 --- /dev/null +++ b/server/libs/cattrs-26.1.0.dist-info/INSTALLER @@ -0,0 +1 @@ +uv \ No newline at end of file diff --git a/server/libs/cattrs-25.1.1.dist-info/METADATA b/server/libs/cattrs-26.1.0.dist-info/METADATA similarity index 95% rename from server/libs/cattrs-25.1.1.dist-info/METADATA rename to server/libs/cattrs-26.1.0.dist-info/METADATA index 302a49f..ec39b01 100644 --- a/server/libs/cattrs-25.1.1.dist-info/METADATA +++ b/server/libs/cattrs-26.1.0.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: cattrs -Version: 25.1.1 +Version: 26.1.0 Summary: Composable complex class support for attrs and dataclasses. Project-URL: Homepage, https://catt.rs Project-URL: Changelog, https://catt.rs/en/latest/history.html @@ -14,18 +14,18 @@ Keywords: attrs,dataclasses,serialization Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License -Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Typing :: Typed -Requires-Python: >=3.9 -Requires-Dist: attrs>=24.3.0 +Requires-Python: >=3.10 +Requires-Dist: attrs>=25.4.0 Requires-Dist: exceptiongroup>=1.1.1; python_version < '3.11' -Requires-Dist: typing-extensions>=4.12.2 +Requires-Dist: typing-extensions>=4.14.0 Provides-Extra: bson Requires-Dist: pymongo>=4.4.0; extra == 'bson' Provides-Extra: cbor2 @@ -35,11 +35,14 @@ Requires-Dist: msgpack>=1.0.5; extra == 'msgpack' Provides-Extra: msgspec Requires-Dist: msgspec>=0.19.0; (implementation_name == 'cpython') and extra == 'msgspec' Provides-Extra: orjson -Requires-Dist: orjson>=3.10.7; (implementation_name == 'cpython') and extra == 'orjson' +Requires-Dist: orjson>=3.11.3; (implementation_name == 'cpython') and extra == 'orjson' Provides-Extra: pyyaml Requires-Dist: pyyaml>=6.0; extra == 'pyyaml' Provides-Extra: tomlkit Requires-Dist: tomlkit>=0.11.8; extra == 'tomlkit' +Provides-Extra: tomllib +Requires-Dist: tomli-w>=1.1.0; extra == 'tomllib' +Requires-Dist: tomli>=1.1.0; (python_version < '3.11') and extra == 'tomllib' Provides-Extra: ujson Requires-Dist: ujson>=5.10.0; extra == 'ujson' Description-Content-Type: text/markdown diff --git a/server/libs/cattrs-26.1.0.dist-info/RECORD b/server/libs/cattrs-26.1.0.dist-info/RECORD new file mode 100644 index 0000000..e2cae53 --- /dev/null +++ b/server/libs/cattrs-26.1.0.dist-info/RECORD @@ -0,0 +1,58 @@ +cattr/__init__.py,sha256=bYrmwTYSdYC_ut1xW31V7mxhXBlJQKs8EECgtUBgAuc,906 +cattr/converters.py,sha256=rQhY4J8r7QTZh5WICuFe4GWO1v0DS3DgQ9r569zd6jg,192 +cattr/disambiguators.py,sha256=ugD1fq1Z5x1pGu5P1lMzcT-IEi1q7IfQJIHEdmg62vM,103 +cattr/dispatch.py,sha256=uVEOgHWR9Hn5tm-wIw-bDccqrxJByVi8yRKaYyvL67k,125 +cattr/errors.py,sha256=V4RhoCObwGrlaM3oyn1H_FYxGR8iAB9dG5NxFDYM548,343 +cattr/gen.py,sha256=hWyKoZ_d2D36Jz_npspyGw8s9pWtUA69sXf0R3uOvgM,597 +cattr/preconf/__init__.py,sha256=NqPE7uhVfcP-PggkUpsbfAutMo8oHjcoB1cvjgLft-s,78 +cattr/preconf/bson.py,sha256=Bn4hJxac7OthGg_CR4LCPeBp_fz4kx3QniBVOZhguGs,195 +cattr/preconf/json.py,sha256=LpqYuO3oePDxbQtKFKB0SaoeAi3Z_agIgyNn1VQSIVo,206 +cattr/preconf/msgpack.py,sha256=pyJ9L9ekNlZ0IQHbJ9Ay_fi_NOqY5_rE_q-UnD94-RM,207 +cattr/preconf/orjson.py,sha256=Adh-7csx4eqCjx22zipMFgSlDXbR554wvgNHEb8Q5JM,203 +cattr/preconf/pyyaml.py,sha256=Fy40bejjp7uqgoLhTA_p4wZYF0uFaguHbUK9zs9LoC0,203 +cattr/preconf/tomlkit.py,sha256=_gADJ_UYpj3EiNXGYjAfSOkcoFIkLpYVOFfLEqBfIJQ,207 +cattr/preconf/ujson.py,sha256=IzEa7QUcYOaSUMiLQsFEWJnBihmmOLhehsM-5cPY9NI,199 +cattr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +cattrs-26.1.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2 +cattrs-26.1.0.dist-info/METADATA,sha256=d94QudQ0gM0Zy_KNPtL7n2gEea7s-Vir2t4_rLD4zLA,8542 +cattrs-26.1.0.dist-info/RECORD,, +cattrs-26.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +cattrs-26.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87 +cattrs-26.1.0.dist-info/licenses/LICENSE,sha256=9fudHt43qIykf0IMSZ3KD0oFvJk-Esd9I1IKrSkcAb8,1074 +cattrs/__init__.py,sha256=UhiFdxf81gCuBBA6FutoE1oOzthzF_PkAdoE2AVslIo,1901 +cattrs/_compat.py,sha256=5dOpD6O8zUVZcreaJ5wSt9n-Nut4n79ibrka5Td6l84,12251 +cattrs/_generics.py,sha256=ERYo_kX-Z6UiwOcYI3WLrlHO6Ya66c_whpko6es5UeI,966 +cattrs/cols.py,sha256=-KmFikvZu7cL_g68ytx0Ad6danfjVY5Ry3w1XRtTaWk,10336 +cattrs/converters.py,sha256=BPpSPuhMuhQz2qFl_OAeIZ8CrI95A8gy6khuvU35bN8,55322 +cattrs/disambiguators.py,sha256=uydD6QXPve1-0dY_3m_FoyOqowvTvEKXzrdb-5xkRPE,6863 +cattrs/dispatch.py,sha256=9qA-pmsPvgrM6MGP8Ev2gVP6YL2rXvoBla_C0VgHxQ0,6780 +cattrs/enums.py,sha256=noADdwXP9TSpyIyjE6WR2iIbGPmigirvArKAoHWJmDI,1130 +cattrs/errors.py,sha256=CLW6Uev31cOxo6cWFb0-g-FAbYJD2mYmR5xTxDIVjDw,4344 +cattrs/fns.py,sha256=z5z1VZOZv8t5LwG8cBM_tIXg-_PlQUOyZb9wIrXNqlw,626 +cattrs/gen/__init__.py,sha256=wrmi87jGEfmWcbbNkZXk1Gqn9PLwXerKUDJoCQBCRek,42553 +cattrs/gen/_consts.py,sha256=ZwT_m2J3S7p-UjltpbA1WtfQZLNj9KhmFYCAv6Zl-g0,511 +cattrs/gen/_generics.py,sha256=_DyXCGql2QIxGhAv3_B1hsi80uPK8PhK2hhZa95YOlo,3011 +cattrs/gen/_lc.py,sha256=4fjeUsmgQcCAIjnNndBic0gf5qKmxVS3CZHqUQ9Rw5g,882 +cattrs/gen/_shared.py,sha256=QctJIsgfhZqgdALdD-L9FUEX1k2uNUpJQW4NbcGLVWQ,2747 +cattrs/gen/typeddicts.py,sha256=svG8RjqNt411KzkRFEKp6zsJHJvrwZNe1UQIhQewv7w,22085 +cattrs/literals.py,sha256=0kzAewmWk9ikJGoKq4ysnAR22DMawG3iNqLl8NLgpk0,331 +cattrs/preconf/__init__.py,sha256=YMY1ADc7OzpuZ2cdQZbH5kUKZF30UYd6owf3BOT4TO0,1435 +cattrs/preconf/bson.py,sha256=I0632WE3L7VN5aA9T3PJ1Jq_iy5XOfTsjJcKjQSEMAM,4211 +cattrs/preconf/cbor2.py,sha256=o13uyALyX2dvxp6HRYq_xHeB84nEGyBOB0V8OcohY30,2035 +cattrs/preconf/json.py,sha256=JiAetUezqQ_ixjRydLCavkvnIQxQCTyQXKzViC-vO6A,2644 +cattrs/preconf/msgpack.py,sha256=12c3sE5tfIVVhxHD4QmZaSGmxTvwHfrtTdk0ohOCcLI,2338 +cattrs/preconf/msgspec.py,sha256=Hea-jCkcqH7wtONAHjpe9EaaqhGodQFfkLtKuWdV09k,7384 +cattrs/preconf/orjson.py,sha256=o1GZwvH2hlLbovmsB7OH3fDkcROhHOAQKMsrDW3VTsY,3883 +cattrs/preconf/pyyaml.py,sha256=w0aM_gJ6VhZf-Zpu_UlJki7rdgv4mfaSXElPofB3nlE,2378 +cattrs/preconf/tomlkit.py,sha256=sPrBZgq7Zg78y31SAPhBuSWQllGxXXysyI-2-T6MY6U,3311 +cattrs/preconf/tomllib.py,sha256=wDOhVcIkBR0bw5zMACPlUcf2YXv-eamgg-GmGdv4cSU,3083 +cattrs/preconf/ujson.py,sha256=ev1fdMvQdP-PSDqDbJ714Oy3Xxi2lbc6qVSZ-LGdgKg,2438 +cattrs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +cattrs/strategies/__init__.py,sha256=nkZWCzSRYcS-75FMfk52mioZSuWykaN8hB39Vig5Xkg,339 +cattrs/strategies/_class_methods.py,sha256=O5xhQCzNpuFiDNDMlbcyeOVqyrV65NhMZNRsG3jnoBU,2591 +cattrs/strategies/_subclasses.py,sha256=8fTr7CLbNlV2Nobi0yXkQ9OKpGyUENmEzyYAsy4SDJw,9852 +cattrs/strategies/_unions.py,sha256=XQwrnPEE7KhBqOy5vyUZ06reQPfqcO85jdVjQ7i3HFk,10070 +cattrs/subclasses.py,sha256=SyAaJ84sO3aen_dBQkuSAIg0eGOXYX-aF151d7IbiNk,783 +cattrs/typealiases.py,sha256=toHavC2kJsIcxThwvATPO5JShzKeC8kIl9KqteFohbw,1619 +cattrs/types.py,sha256=cqvfmzliYfrvPswxlW_tN4DmhQ2xpAKQvVbNBJaxiWs,278 +cattrs/v.py,sha256=IqUajgJFCKJYf-4S9TCKRtJcmmK4c3En69TGuf2FKOs,4126 diff --git a/server/libs/packaging-25.0.dist-info/REQUESTED b/server/libs/cattrs-26.1.0.dist-info/REQUESTED similarity index 100% rename from server/libs/packaging-25.0.dist-info/REQUESTED rename to server/libs/cattrs-26.1.0.dist-info/REQUESTED diff --git a/server/libs/attrs-25.3.0.dist-info/WHEEL b/server/libs/cattrs-26.1.0.dist-info/WHEEL similarity index 67% rename from server/libs/attrs-25.3.0.dist-info/WHEEL rename to server/libs/cattrs-26.1.0.dist-info/WHEEL index 12228d4..ae8ec1b 100644 --- a/server/libs/attrs-25.3.0.dist-info/WHEEL +++ b/server/libs/cattrs-26.1.0.dist-info/WHEEL @@ -1,4 +1,4 @@ Wheel-Version: 1.0 -Generator: hatchling 1.27.0 +Generator: hatchling 1.28.0 Root-Is-Purelib: true Tag: py3-none-any diff --git a/server/libs/cattrs-25.1.1.dist-info/licenses/LICENSE b/server/libs/cattrs-26.1.0.dist-info/licenses/LICENSE similarity index 100% rename from server/libs/cattrs-25.1.1.dist-info/licenses/LICENSE rename to server/libs/cattrs-26.1.0.dist-info/licenses/LICENSE diff --git a/server/libs/cattrs/_compat.py b/server/libs/cattrs/_compat.py index da50c22..9a58b1f 100644 --- a/server/libs/cattrs/_compat.py +++ b/server/libs/cattrs/_compat.py @@ -29,7 +29,6 @@ from typing import ( _AnnotatedAlias, _GenericAlias, _SpecialGenericAlias, - _UnionGenericAlias, get_args, get_origin, get_type_hints, @@ -209,10 +208,7 @@ def get_final_base(type) -> Optional[type]: OriginAbstractSet = AbcSet OriginMutableSet = AbcMutableSet -signature = _signature - -if sys.version_info >= (3, 10): - signature = partial(_signature, eval_str=True) +signature = partial(_signature, eval_str=True) try: @@ -256,10 +252,25 @@ def is_tuple(type): ) -if sys.version_info >= (3, 10): +if sys.version_info >= (3, 14): def is_union_type(obj): - from types import UnionType + from types import UnionType # noqa: PLC0415 + + return obj is Union or isinstance(obj, UnionType) + + def get_newtype_base(typ: Any) -> Optional[type]: + if typ is NewType or isinstance(typ, NewType): + return typ.__supertype__ + return None + + from typing import NotRequired, Required + +else: + from typing import _UnionGenericAlias + + def is_union_type(obj): + from types import UnionType # noqa: PLC0415 return ( obj is Union @@ -277,25 +288,6 @@ if sys.version_info >= (3, 10): else: from typing_extensions import NotRequired, Required -else: - # 3.9 - from typing_extensions import NotRequired, Required - - def is_union_type(obj): - return obj is Union or ( - isinstance(obj, _UnionGenericAlias) and obj.__origin__ is Union - ) - - def get_newtype_base(typ: Any) -> Optional[type]: - supertype = getattr(typ, "__supertype__", None) - if ( - supertype is not None - and getattr(typ, "__qualname__", "") == "NewType..new_type" - and typ.__module__ in ("typing", "typing_extensions") - ): - return supertype - return None - def get_notrequired_base(type) -> Union[Any, NothingType]: if is_annotated(type): @@ -306,6 +298,25 @@ def get_notrequired_base(type) -> Union[Any, NothingType]: return NOTHING +def is_mutable_sequence(type: Any) -> bool: + """A predicate function for mutable sequences. + + Matches lists, mutable sequences, and deques. + """ + origin = getattr(type, "__origin__", None) + return ( + type in (List, list, TypingMutableSequence, AbcMutableSequence, deque, Deque) + or ( + type.__class__ is _GenericAlias + and ( + ((origin is not tuple) and is_subclass(origin, TypingMutableSequence)) + or (origin is tuple and type.__args__[1] is ...) + ) + ) + or (origin in (list, deque, AbcMutableSequence)) + ) + + def is_sequence(type: Any) -> bool: """A predicate function for sequences. @@ -313,19 +324,8 @@ def is_sequence(type: Any) -> bool: tuples. """ origin = getattr(type, "__origin__", None) - return ( - type - in ( - List, - list, - TypingSequence, - TypingMutableSequence, - AbcMutableSequence, - tuple, - Tuple, - deque, - Deque, - ) + return is_mutable_sequence(type) or ( + type in (TypingSequence, tuple, Tuple) or ( type.__class__ is _GenericAlias and ( @@ -333,7 +333,7 @@ def is_sequence(type: Any) -> bool: or (origin is tuple and type.__args__[1] is ...) ) ) - or (origin in (list, deque, AbcMutableSequence, AbcSequence)) + or (origin is AbcSequence) or (origin is tuple and type.__args__[1] is ...) ) @@ -403,8 +403,10 @@ def is_generic(type) -> bool: """Whether `type` is a generic type.""" # Inheriting from protocol will inject `Generic` into the MRO # without `__orig_bases__`. - return isinstance(type, (_GenericAlias, GenericAlias)) or ( - is_subclass(type, Generic) and hasattr(type, "__orig_bases__") + return ( + isinstance(type, (_GenericAlias, GenericAlias)) + or (is_subclass(type, Generic) and hasattr(type, "__orig_bases__")) + or type.__class__ is Union # On 3.14, unions are no longer typing._GenericAlias ) diff --git a/server/libs/cattrs/_generics.py b/server/libs/cattrs/_generics.py index 6f36e94..5d3fde8 100644 --- a/server/libs/cattrs/_generics.py +++ b/server/libs/cattrs/_generics.py @@ -1,10 +1,10 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, get_args from attrs import NOTHING from typing_extensions import Self -from ._compat import copy_with, get_args, is_annotated, is_generic +from ._compat import copy_with, is_annotated, is_generic def deep_copy_with(t, mapping: Mapping[str, Any], self_is=NOTHING): diff --git a/server/libs/cattrs/cols.py b/server/libs/cattrs/cols.py index 0b578eb..64e4f2c 100644 --- a/server/libs/cattrs/cols.py +++ b/server/libs/cattrs/cols.py @@ -5,25 +5,20 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable from functools import partial -from typing import ( - TYPE_CHECKING, - Any, - DefaultDict, - Literal, - NamedTuple, - TypeVar, - get_type_hints, -) +from typing import TYPE_CHECKING, Any, DefaultDict, Literal, NamedTuple, TypeVar from attrs import NOTHING, Attribute, NothingType from ._compat import ( ANIES, + AbcSet, get_args, + get_full_type_hints, get_origin, is_bare, is_frozenset, is_mapping, + is_mutable_sequence, is_sequence, is_subclass, ) @@ -47,10 +42,13 @@ if TYPE_CHECKING: __all__ = [ "defaultdict_structure_factory", + "homogenous_tuple_structure_factory", + "is_abstract_set", "is_any_set", "is_defaultdict", "is_frozenset", "is_mapping", + "is_mutable_sequence", "is_namedtuple", "is_sequence", "is_set", @@ -70,6 +68,11 @@ def is_any_set(type) -> bool: return is_set(type) or is_frozenset(type) +def is_abstract_set(type) -> bool: + """A predicate function for abstract (collection.abc) sets.""" + return type is AbcSet or (getattr(type, "__origin__", None) is AbcSet) + + def is_namedtuple(type: Any) -> bool: """A predicate function for named tuples.""" @@ -151,6 +154,47 @@ def list_structure_factory(type: type, converter: BaseConverter) -> StructureHoo return structure_list +def homogenous_tuple_structure_factory( + type: type, converter: BaseConverter +) -> StructureHook: + """A hook factory for homogenous (all elements the same, indeterminate length) tuples. + + Converts any given iterable into a tuple. + """ + + if is_bare(type) or type.__args__[0] in ANIES: + + def structure_tuple(obj: Iterable[T], _: type = type) -> tuple[T, ...]: + return tuple(obj) + + return structure_tuple + + elem_type = type.__args__[0] + + try: + handler = converter.get_structure_hook(elem_type) + except RecursionError: + # Break the cycle by using late binding. + handler = converter.structure + + if converter.detailed_validation: + + # We have to structure into a list first anyway. + list_structure = list_structure_factory(type, converter) + + def structure_tuple(obj: Iterable[T], _: type = type) -> tuple[T, ...]: + return tuple(list_structure(obj, _)) + + else: + + def structure_tuple( + obj: Iterable[T], _: type = type, _handler=handler, _elem_type=elem_type + ) -> tuple[T, ...]: + return tuple([_handler(e, _elem_type) for e in obj]) + + return structure_tuple + + def namedtuple_unstructure_factory( cl: type[tuple], converter: BaseConverter, unstructure_to: Any = None ) -> UnstructureHook: @@ -195,7 +239,7 @@ def _namedtuple_to_attrs(cl: type[tuple]) -> list[Attribute]: type=a, alias=name, ) - for name, a in get_type_hints(cl).items() + for name, a in get_full_type_hints(cl).items() ] diff --git a/server/libs/cattrs/converters.py b/server/libs/cattrs/converters.py index 9a54476..54d67a4 100644 --- a/server/libs/cattrs/converters.py +++ b/server/libs/cattrs/converters.py @@ -43,10 +43,11 @@ from ._compat import ( is_hetero_tuple, is_literal, is_mapping, + is_mutable_sequence, is_mutable_set, is_optional, is_protocol, - is_sequence, + is_subclass, is_tuple, is_typeddict, is_union_type, @@ -54,8 +55,11 @@ from ._compat import ( ) from .cols import ( defaultdict_structure_factory, + homogenous_tuple_structure_factory, + is_abstract_set, is_defaultdict, is_namedtuple, + is_sequence, iterable_unstructure_factory, list_structure_factory, mapping_structure_factory, @@ -73,6 +77,7 @@ from .dispatch import ( UnstructuredValue, UnstructureHook, ) +from .enums import enum_structure_factory, enum_unstructure_factory from .errors import ( IterableValidationError, IterableValidationNote, @@ -225,6 +230,10 @@ class BaseConverter: ) self._unstructure_func.register_func_list( [ + ( + lambda t: get_newtype_base(t) is not None, + lambda o: self.unstructure(o, unstructure_as=o.__class__), + ), ( is_protocol, lambda o: self.unstructure(o, unstructure_as=o.__class__), @@ -239,12 +248,12 @@ class BaseConverter: lambda t: self.get_unstructure_hook(get_type_alias_base(t)), True, ), - (is_literal_containing_enums, self.unstructure), (is_mapping, self._unstructure_mapping), (is_sequence, self._unstructure_seq), (is_mutable_set, self._unstructure_seq), (is_frozenset, self._unstructure_seq), - (lambda t: issubclass(t, Enum), self._unstructure_enum), + (is_literal_containing_enums, self.unstructure), + (lambda t: is_subclass(t, Enum), enum_unstructure_factory, "extended"), (has, self._unstructure_attrs), (is_union_type, self._unstructure_union), (lambda t: t in ANIES, self.unstructure), @@ -271,20 +280,27 @@ class BaseConverter: ), (is_literal, self._structure_simple_literal), (is_literal_containing_enums, self._structure_enum_literal), - (is_sequence, list_structure_factory, "extended"), + (is_sequence, homogenous_tuple_structure_factory, "extended"), + (is_mutable_sequence, list_structure_factory, "extended"), (is_deque, self._structure_deque), (is_mutable_set, self._structure_set), + (is_abstract_set, self._structure_frozenset), (is_frozenset, self._structure_frozenset), (is_tuple, self._structure_tuple), (is_namedtuple, namedtuple_structure_factory, "extended"), (is_mapping, self._structure_dict), - (is_supported_union, self._gen_attrs_union_structure, True), + *( + [(is_supported_union, self._gen_attrs_union_structure, True)] + if unstruct_strat is UnstructureStrategy.AS_DICT + else [] + ), (is_optional, self._structure_optional), ( lambda t: is_union_type(t) and t in self._union_struct_registry, self._union_struct_registry.__getitem__, True, ), + (lambda t: is_subclass(t, Enum), enum_structure_factory, "extended"), (has, self._structure_attrs), ] ) @@ -295,7 +311,6 @@ class BaseConverter: (bytes, self._structure_call), (int, self._structure_call), (float, self._structure_call), - (Enum, self._structure_call), (Path, self._structure_call), ] ) @@ -617,10 +632,6 @@ class BaseConverter: res.append(dispatch(a.type or v.__class__)(v)) return tuple(res) - def _unstructure_enum(self, obj: Enum) -> Any: - """Convert an enum to its value.""" - return obj.value - def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]: """Convert a sequence to primitive equivalents.""" # We can reuse the sequence class, so tuples stay tuples. @@ -1034,6 +1045,7 @@ class Converter(BaseConverter): "forbid_extra_keys", "omit_if_default", "type_overrides", + "use_alias", ) def __init__( @@ -1050,6 +1062,7 @@ class Converter(BaseConverter): structure_fallback_factory: HookFactory[StructureHook] = lambda t: raise_error( None, t ), + use_alias: bool = False, ): """ :param detailed_validation: Whether to use a slightly slower mode for detailed @@ -1058,12 +1071,15 @@ class Converter(BaseConverter): registered unstructuring hooks match. :param structure_fallback_factory: A hook factory to be called when no registered structuring hooks match. + :param use_alias: Whether to use the field alias instead of the field name as + the un/structured dictionary key by default. .. versionadded:: 23.2.0 *unstructure_fallback_factory* .. versionadded:: 23.2.0 *structure_fallback_factory* .. versionchanged:: 24.2.0 The default `structure_fallback_factory` now raises errors for missing handlers more eagerly, surfacing problems earlier. + .. versionadded:: 25.2.0 *use_alias* """ super().__init__( dict_factory=dict_factory, @@ -1076,6 +1092,7 @@ class Converter(BaseConverter): self.omit_if_default = omit_if_default self.forbid_extra_keys = forbid_extra_keys self.type_overrides = dict(type_overrides) + self.use_alias = use_alias unstruct_collection_overrides = { get_origin(k) or k: v for k, v in unstruct_collection_overrides.items() @@ -1246,7 +1263,7 @@ class Converter(BaseConverter): attribs = fields(origin or cl) if attrs_has(cl) and any(isinstance(a.type, str) for a in attribs): # PEP 563 annotations - need to be resolved. - resolve_types(cl) + resolve_types(origin or cl) attrib_overrides = { a.name: self.type_overrides[a.type] for a in attribs @@ -1284,10 +1301,11 @@ class Converter(BaseConverter): def gen_structure_attrs_fromdict( self, cl: type[T] ) -> Callable[[Mapping[str, Any], Any], T]: - attribs = fields(get_origin(cl) or cl if is_generic(cl) else cl) + origin = get_origin(cl) + attribs = fields(origin or cl if is_generic(cl) else cl) if attrs_has(cl) and any(isinstance(a.type, str) for a in attribs): # PEP 563 annotations - need to be resolved. - resolve_types(cl) + resolve_types(origin or cl) attrib_overrides = { a.name: self.type_overrides[a.type] for a in attribs @@ -1299,6 +1317,7 @@ class Converter(BaseConverter): _cattrs_forbid_extra_keys=self.forbid_extra_keys, _cattrs_prefer_attrib_converters=self._prefer_attrib_converters, _cattrs_detailed_validation=self.detailed_validation, + _cattrs_use_alias=self.use_alias, **attrib_overrides, ) @@ -1377,6 +1396,7 @@ class Converter(BaseConverter): unstruct_collection_overrides: Mapping[type, UnstructureHook] | None = None, prefer_attrib_converters: bool | None = None, detailed_validation: bool | None = None, + use_alias: bool | None = None, ) -> Self: """Create a copy of the converter, keeping all existing custom hooks. @@ -1416,6 +1436,7 @@ class Converter(BaseConverter): if detailed_validation is not None else self.detailed_validation ), + use_alias=(use_alias if use_alias is not None else self.use_alias), ) self._unstructure_func.copy_to( diff --git a/server/libs/cattrs/disambiguators.py b/server/libs/cattrs/disambiguators.py index 6fc5d9d..f6e4392 100644 --- a/server/libs/cattrs/disambiguators.py +++ b/server/libs/cattrs/disambiguators.py @@ -7,7 +7,7 @@ from collections.abc import Mapping from dataclasses import MISSING from functools import reduce from operator import or_ -from typing import TYPE_CHECKING, Any, Callable, Literal, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Union, get_origin from attrs import NOTHING, Attribute, AttrsInstance @@ -16,7 +16,6 @@ from ._compat import ( adapted_fields, fields_dict, get_args, - get_origin, has, is_literal, is_union_type, diff --git a/server/libs/cattrs/enums.py b/server/libs/cattrs/enums.py new file mode 100644 index 0000000..b1ab504 --- /dev/null +++ b/server/libs/cattrs/enums.py @@ -0,0 +1,36 @@ +from collections.abc import Callable +from enum import Enum +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .converters import BaseConverter + + +def enum_unstructure_factory( + type: type[Enum], converter: "BaseConverter" +) -> Callable[[Enum], Any]: + """A factory for generating enum unstructure hooks. + + If the enum is a typed enum (has `_value_`), we use the underlying value's hook. + Otherwise, we use the value directly. + """ + if "_value_" in type.__annotations__: + return lambda e: converter.unstructure(e.value) + + return lambda e: e.value + + +def enum_structure_factory( + type: type[Enum], converter: "BaseConverter" +) -> Callable[[Any, type[Enum]], Enum]: + """A factory for generating enum structure hooks. + + If the enum is a typed enum (has `_value_`), we structure the value first. + Otherwise, we use the value directly. + """ + if "_value_" in type.__annotations__: + val_type = type.__annotations__["_value_"] + val_hook = converter.get_structure_hook(val_type) + return lambda v, _: type(val_hook(v, val_type)) + + return lambda v, _: type(v) diff --git a/server/libs/cattrs/errors.py b/server/libs/cattrs/errors.py index 4f9a737..21012bb 100644 --- a/server/libs/cattrs/errors.py +++ b/server/libs/cattrs/errors.py @@ -13,14 +13,18 @@ class StructureHandlerNotFoundError(Exception): """ def __init__(self, message: str, type_: type) -> None: - super().__init__(message) + super().__init__(message, type_) + self.message = message self.type_ = type_ + def __str__(self) -> str: + return self.message + class BaseValidationError(ExceptionGroup): cl: type - def __new__(cls, message: str, excs: Sequence[Exception], cl: type): + def __new__(cls, message: str, excs: Sequence[Exception], cl: type) -> Self: obj = super().__new__(cls, message, excs) obj.cl = cl return obj @@ -35,9 +39,7 @@ class IterableValidationNote(str): index: Union[int, str] # Ints for list indices, strs for dict keys type: Any - def __new__( - cls, string: str, index: Union[int, str], type: Any - ) -> "IterableValidationNote": + def __new__(cls, string: str, index: Union[int, str], type: Any) -> Self: instance = str.__new__(cls, string) instance.index = index instance.type = type @@ -76,7 +78,7 @@ class AttributeValidationNote(str): name: str type: Any - def __new__(cls, string: str, name: str, type: Any) -> "AttributeValidationNote": + def __new__(cls, string: str, name: str, type: Any) -> Self: instance = str.__new__(cls, string) instance.name = name instance.type = type @@ -122,11 +124,15 @@ class ForbiddenExtraKeysError(Exception): def __init__( self, message: Optional[str], cl: type, extra_fields: set[str] ) -> None: + self.message = message self.cl = cl self.extra_fields = extra_fields - cln = cl.__name__ - super().__init__( - message - or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}" + super().__init__(message, cl, extra_fields) + + def __str__(self) -> str: + return ( + self.message + or f"Extra fields in constructor for {self.cl.__name__}: " + f"{', '.join(sorted(self.extra_fields))}" ) diff --git a/server/libs/cattrs/gen/__init__.py b/server/libs/cattrs/gen/__init__.py index 3afa3b9..3e386fa 100644 --- a/server/libs/cattrs/gen/__init__.py +++ b/server/libs/cattrs/gen/__init__.py @@ -4,7 +4,7 @@ import re from collections.abc import Callable, Iterable, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar -from attrs import NOTHING, Attribute, Factory +from attrs import NOTHING, Attribute, Converter, Factory, evolve from typing_extensions import NoDefault from .._compat import ( @@ -33,7 +33,7 @@ from ..types import SimpleStructureHook from ._consts import AttributeOverride, already_generating, neutral from ._generics import generate_mapping from ._lc import generate_unique_filename -from ._shared import find_structure_handler +from ._shared import _annotated_override_or_default, find_structure_handler if TYPE_CHECKING: from ..converters import BaseConverter @@ -74,7 +74,7 @@ def make_dict_unstructure_fn_from_attrs( typevar_map: dict[str, Any] = {}, _cattrs_omit_if_default: bool = False, _cattrs_use_linecache: bool = True, - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> Callable[[T], dict[str, Any]]: @@ -95,7 +95,17 @@ def make_dict_unstructure_fn_from_attrs( :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` will be included. - .. versionadded:: 24.1.0 + .. versionadded:: 24.1.0 + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. + .. versionchanged:: 26.1.0 + `typing.Annotated[T, override()]` is now recognized and can be used to customize + unstructuring. + .. versionchanged:: 26.1.0 + When `_cattrs_omit_if_default` is true and the attribute has an attrs converter + specified, the converter is applied to the default value before checking if it + is equal to the attribute's value. """ fn_name = "unstructure_" + cl.__name__ @@ -104,15 +114,27 @@ def make_dict_unstructure_fn_from_attrs( invocation_lines = [] internal_arg_parts = {} + if _cattrs_use_alias == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_use_alias = getattr(converter, "use_alias", False) + for a in attrs: attr_name = a.name - override = kwargs.get(attr_name, neutral) + if attr_name in kwargs: + override = kwargs[attr_name] + else: + override = _annotated_override_or_default(a.type, neutral) + if override != neutral: + kwargs[attr_name] = override + if override.omit: continue if override.omit is None and not a.init and not _cattrs_include_init_false: continue if override.rename is None: kn = attr_name if not _cattrs_use_alias else a.alias + if kn != attr_name: + kwargs[attr_name] = evolve(override, rename=kn) else: kn = override.rename d = a.default @@ -170,16 +192,32 @@ def make_dict_unstructure_fn_from_attrs( if isinstance(d, Factory): globs[def_name] = d.factory internal_arg_parts[def_name] = d.factory - if d.takes_self: - lines.append(f" if instance.{attr_name} != {def_name}(instance):") - else: - lines.append(f" if instance.{attr_name} != {def_name}():") - lines.append(f" res['{kn}'] = {invoke}") + def_str = f"{def_name}(instance)" if d.takes_self else f"{def_name}()" else: globs[def_name] = d internal_arg_parts[def_name] = d - lines.append(f" if instance.{attr_name} != {def_name}:") - lines.append(f" res['{kn}'] = {invoke}") + def_str = def_name + + c = a.converter + if c is not None: + conv_name = f"__c_conv_{attr_name}" + if isinstance(c, Converter): + globs[conv_name] = c + internal_arg_parts[conv_name] = c + field_name = f"__c_field_{attr_name}" + globs[field_name] = a + internal_arg_parts[field_name] = a + def_str = f"{conv_name}({def_str}, instance, {field_name})" + elif isinstance(d, Factory): + globs[conv_name] = c + internal_arg_parts[conv_name] = c + def_str = f"{conv_name}({def_str})" + else: + globs[def_name] = c(d) + internal_arg_parts[def_name] = c(d) + + lines.append(f" if instance.{attr_name} != {def_str}:") + lines.append(f" res['{kn}'] = {invoke}") else: # No default or no override. @@ -217,7 +255,7 @@ def make_dict_unstructure_fn( converter: BaseConverter, _cattrs_omit_if_default: bool = False, _cattrs_use_linecache: bool = True, - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> Callable[[T], dict[str, Any]]: @@ -235,13 +273,22 @@ def make_dict_unstructure_fn( :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` will be included. - .. versionadded:: 23.2.0 *_cattrs_use_alias* - .. versionadded:: 23.2.0 *_cattrs_include_init_false* + .. versionadded:: 23.2.0 *_cattrs_use_alias* + .. versionadded:: 23.2.0 *_cattrs_include_init_false* + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. + .. versionchanged:: 26.1.0 + `typing.Annotated[T, override()]` is now recognized and can be used to customize + unstructuring. """ origin = get_origin(cl) attrs = adapted_fields(origin or cl) # type: ignore mapping = {} + if _cattrs_use_alias == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_use_alias = getattr(converter, "use_alias", False) if is_generic(cl): mapping = generate_mapping(cl, mapping) @@ -289,7 +336,7 @@ def make_dict_structure_fn_from_attrs( bool | Literal["from_converter"] ) = "from_converter", _cattrs_detailed_validation: bool | Literal["from_converter"] = "from_converter", - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> SimpleStructureHook[Mapping[str, Any], T]: @@ -314,7 +361,13 @@ def make_dict_structure_fn_from_attrs( :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` will be included. - .. versionadded:: 24.1.0 + .. versionadded:: 24.1.0 + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. + .. versionchanged:: 26.1.0 + `typing.Annotated[T, override()]` is now recognized and can be used to customize + unstructuring. """ cl_name = cl.__name__ @@ -350,6 +403,9 @@ def make_dict_structure_fn_from_attrs( if _cattrs_forbid_extra_keys == "from_converter": # BaseConverter doesn't have it so we're careful. _cattrs_forbid_extra_keys = getattr(converter, "forbid_extra_keys", False) + if _cattrs_use_alias == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_use_alias = getattr(converter, "use_alias", False) if _cattrs_detailed_validation == "from_converter": _cattrs_detailed_validation = converter.detailed_validation if _cattrs_prefer_attrib_converters == "from_converter": @@ -367,7 +423,13 @@ def make_dict_structure_fn_from_attrs( internal_arg_parts["__c_avn"] = AttributeValidationNote for a in attrs: an = a.name - override = kwargs.get(an, neutral) + if an in kwargs: + override = kwargs[an] + else: + override = _annotated_override_or_default(a.type, neutral) + if override != neutral: + kwargs[an] = override + if override.omit: continue if override.omit is None and not a.init and not _cattrs_include_init_false: @@ -396,6 +458,8 @@ def make_dict_structure_fn_from_attrs( ian = a.alias if override.rename is None: kn = an if not _cattrs_use_alias else a.alias + if kn != an: + kwargs[an] = evolve(override, rename=kn) else: kn = override.rename @@ -496,14 +560,24 @@ def make_dict_structure_fn_from_attrs( # The first loop deals with required args. for a in attrs: an = a.name - override = kwargs.get(an, neutral) + + if an in kwargs: + override = kwargs[an] + else: + override = _annotated_override_or_default(a.type, neutral) + if override != neutral: + kwargs[an] = override + if override.omit: continue if override.omit is None and not a.init and not _cattrs_include_init_false: continue + if a.default is not NOTHING: non_required.append(a) + # The next loop will handle it. continue + t = a.type if isinstance(t, TypeVar): t = typevar_map.get(t.__name__, t) @@ -523,6 +597,8 @@ def make_dict_structure_fn_from_attrs( if override.rename is None: kn = an if not _cattrs_use_alias else a.alias + if kn != an: + kwargs[an] = evolve(override, rename=kn) else: kn = override.rename allowed_fields.add(kn) @@ -592,6 +668,8 @@ def make_dict_structure_fn_from_attrs( if override.rename is None: kn = an if not _cattrs_use_alias else a.alias + if kn != an: + kwargs[an] = evolve(override, rename=kn) else: kn = override.rename allowed_fields.add(kn) @@ -682,7 +760,7 @@ def make_dict_structure_fn( bool | Literal["from_converter"] ) = "from_converter", _cattrs_detailed_validation: bool | Literal["from_converter"] = "from_converter", - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> SimpleStructureHook[Mapping[str, Any], T]: @@ -706,14 +784,20 @@ def make_dict_structure_fn( :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` will be included. - .. versionadded:: 23.2.0 *_cattrs_use_alias* - .. versionadded:: 23.2.0 *_cattrs_include_init_false* - .. versionchanged:: 23.2.0 + .. versionadded:: 23.2.0 *_cattrs_use_alias* + .. versionadded:: 23.2.0 *_cattrs_include_init_false* + .. versionchanged:: 23.2.0 The `_cattrs_forbid_extra_keys` and `_cattrs_detailed_validation` parameters take their values from the given converter by default. - .. versionchanged:: 24.1.0 + .. versionchanged:: 24.1.0 The `_cattrs_prefer_attrib_converters` parameter takes its value from the given converter by default. + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. + .. versionchanged:: 26.1.0 + `typing.Annotated[T, override()]` is now recognized and can be used to customize + unstructuring. """ mapping = {} diff --git a/server/libs/cattrs/gen/_shared.py b/server/libs/cattrs/gen/_shared.py index 904c774..967661e 100644 --- a/server/libs/cattrs/gen/_shared.py +++ b/server/libs/cattrs/gen/_shared.py @@ -4,15 +4,31 @@ from typing import TYPE_CHECKING, Any from attrs import NOTHING, Attribute, Factory -from .._compat import is_bare_final +from .._compat import get_args, is_annotated, is_bare_final from ..dispatch import StructureHook from ..errors import StructureHandlerNotFoundError from ..fns import raise_error +from ._consts import AttributeOverride if TYPE_CHECKING: from ..converters import BaseConverter +def _annotated_override_or_default( + type: Any, default: AttributeOverride +) -> AttributeOverride: + """ + If the type is Annotated containing an AttributeOverride, return it. + Otherwise, return the default. + """ + if is_annotated(type): + for arg in get_args(type): + if isinstance(arg, AttributeOverride): + return arg + + return default + + def find_structure_handler( a: Attribute, type: Any, c: BaseConverter, prefer_attrs_converters: bool = False ) -> StructureHook | None: diff --git a/server/libs/cattrs/gen/typeddicts.py b/server/libs/cattrs/gen/typeddicts.py index bca38a5..fc6e173 100644 --- a/server/libs/cattrs/gen/typeddicts.py +++ b/server/libs/cattrs/gen/typeddicts.py @@ -3,23 +3,12 @@ from __future__ import annotations import re import sys from collections.abc import Mapping +from inspect import get_annotations from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar from attrs import NOTHING, Attribute from typing_extensions import _TypedDictMeta -try: - from inspect import get_annotations - - def get_annots(cl) -> dict[str, Any]: - return get_annotations(cl, eval_str=True) - -except ImportError: - # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older - def get_annots(cl) -> dict[str, Any]: - return cl.__dict__.get("__annotations__", {}) - - from .._compat import ( get_full_type_hints, get_notrequired_base, @@ -40,7 +29,7 @@ from . import AttributeOverride from ._consts import already_generating, neutral from ._generics import generate_mapping from ._lc import generate_unique_filename -from ._shared import find_structure_handler +from ._shared import _annotated_override_or_default, find_structure_handler if TYPE_CHECKING: from ..converters import BaseConverter @@ -50,6 +39,10 @@ __all__ = ["make_dict_structure_fn", "make_dict_unstructure_fn"] T = TypeVar("T") +def get_annots(cl) -> dict[str, Any]: + return get_annotations(cl, eval_str=True) + + def make_dict_unstructure_fn( cl: type[T], converter: BaseConverter, @@ -109,11 +102,20 @@ def make_dict_unstructure_fn( # * all attributes resolve to `converter._unstructure_identity` for a in attrs: attr_name = a.name - override = kwargs.get(attr_name, neutral) + t = a.type + nrb = get_notrequired_base(t) + if nrb is not NOTHING: + t = nrb + + if attr_name in kwargs: + override = kwargs[attr_name] + else: + override = _annotated_override_or_default(t, neutral) + if override != neutral: + kwargs[attr_name] = override if override != neutral: break handler = None - t = a.type if isinstance(t, TypeVar): if t.__name__ in mapping: @@ -125,9 +127,6 @@ def make_dict_unstructure_fn( t = deep_copy_with(t, mapping, cl) if handler is None: - nrb = get_notrequired_base(t) - if nrb is not NOTHING: - t = nrb try: handler = converter.get_unstructure_hook(t) except RecursionError: @@ -142,10 +141,22 @@ def make_dict_unstructure_fn( for ix, a in enumerate(attrs): attr_name = a.name - override = kwargs.get(attr_name, neutral) + t = a.type + nrb = get_notrequired_base(t) + if nrb is not NOTHING: + t = nrb + + if attr_name in kwargs: + override = kwargs[attr_name] + else: + override = _annotated_override_or_default(t, neutral) + if override != neutral: + kwargs[attr_name] = override + if override.omit: lines.append(f" res.pop('{attr_name}', None)") continue + if override.rename is not None: # We also need to pop when renaming, since we're copying # the original. @@ -160,8 +171,6 @@ def make_dict_unstructure_fn( if override.unstruct_hook is not None: handler = override.unstruct_hook else: - t = a.type - if isinstance(t, TypeVar): if t.__name__ in mapping: t = mapping[t.__name__] @@ -171,9 +180,6 @@ def make_dict_unstructure_fn( t = deep_copy_with(t, mapping, cl) if handler is None: - nrb = get_notrequired_base(t) - if nrb is not NOTHING: - t = nrb try: handler = converter.get_unstructure_hook(t) except RecursionError: @@ -220,12 +226,15 @@ def make_dict_unstructure_fn( ) eval(compile(script, fname, "exec"), globs) + + res = globs[fn_name] + res.overrides = kwargs finally: working_set.remove(cl) if not working_set: del already_generating.working_set - return globs[fn_name] + return res def make_dict_structure_fn( @@ -326,20 +335,25 @@ def make_dict_structure_fn( for ix, a in enumerate(attrs): an = a.name attr_required = an in req_keys - override = kwargs.get(an, neutral) + t = a.type + nrb = get_notrequired_base(t) + if nrb is not NOTHING: + t = nrb + + if an in kwargs: + override = kwargs[an] + else: + override = _annotated_override_or_default(t, neutral) + if override != neutral: + kwargs[an] = override if override.omit: continue - t = a.type if isinstance(t, TypeVar): t = mapping.get(t.__name__, t) elif is_generic(t) and not is_bare(t) and not is_annotated(t): t = deep_copy_with(t, mapping, cl) - nrb = get_notrequired_base(t) - if nrb is not NOTHING: - t = nrb - if is_generic(t) and not is_bare(t) and not is_annotated(t): t = deep_copy_with(t, mapping, cl) @@ -399,7 +413,12 @@ def make_dict_structure_fn( for ix, a in enumerate(attrs): an = a.name attr_required = an in req_keys - override = kwargs.get(an, neutral) + if an in kwargs: + override = kwargs[an] + else: + override = _annotated_override_or_default(a.type, neutral) + if override != neutral: + kwargs[an] = override if override.omit: continue if not attr_required: @@ -448,13 +467,18 @@ def make_dict_structure_fn( if non_required: for ix, a in non_required: an = a.name - override = kwargs.get(an, neutral) t = a.type - nrb = get_notrequired_base(t) if nrb is not NOTHING: t = nrb + if an in kwargs: + override = kwargs[an] + else: + override = _annotated_override_or_default(t, neutral) + if override != neutral: + kwargs[an] = override + if isinstance(t, TypeVar): t = mapping.get(t.__name__, t) elif is_generic(t) and not is_bare(t) and not is_annotated(t): @@ -514,7 +538,9 @@ def make_dict_structure_fn( ) eval(compile(script, fname, "exec"), globs) - return globs[fn_name] + res = globs[fn_name] + res.overrides = kwargs + return res def _adapted_fields(cls: Any) -> list[Attribute]: diff --git a/server/libs/cattrs/preconf/__init__.py b/server/libs/cattrs/preconf/__init__.py index 1b12ef9..6cab7cb 100644 --- a/server/libs/cattrs/preconf/__init__.py +++ b/server/libs/cattrs/preconf/__init__.py @@ -1,17 +1,12 @@ -import sys +from collections.abc import Callable from datetime import datetime from enum import Enum -from typing import Any, Callable, TypeVar, get_args +from typing import Any, ParamSpec, TypeVar, get_args from .._compat import is_subclass from ..converters import Converter, UnstructureHook from ..fns import identity -if sys.version_info[:2] < (3, 10): - from typing_extensions import ParamSpec -else: - from typing import ParamSpec - def validate_datetime(v, _): if not isinstance(v, datetime): diff --git a/server/libs/cattrs/preconf/bson.py b/server/libs/cattrs/preconf/bson.py index 4957489..9d28a5b 100644 --- a/server/libs/cattrs/preconf/bson.py +++ b/server/libs/cattrs/preconf/bson.py @@ -99,11 +99,11 @@ def configure_converter(converter: BaseConverter): # datetime inherits from date, so identity unstructure hook used # here to prevent the date unstructure hook running. - converter.register_unstructure_hook(datetime, lambda v: v) + converter.register_unstructure_hook(datetime, identity) converter.register_structure_hook(datetime, validate_datetime) converter.register_unstructure_hook(date, lambda v: v.isoformat()) converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v)) - converter.register_unstructure_hook_func(is_primitive_enum, identity) + converter.register_unstructure_hook_factory(is_primitive_enum, lambda t: identity) converter.register_unstructure_hook_factory( is_literal_containing_enums, literals_with_enums_unstructure_factory ) diff --git a/server/libs/cattrs/preconf/cbor2.py b/server/libs/cattrs/preconf/cbor2.py index 6341d89..ad011c8 100644 --- a/server/libs/cattrs/preconf/cbor2.py +++ b/server/libs/cattrs/preconf/cbor2.py @@ -37,7 +37,7 @@ def configure_converter(converter: BaseConverter): ) converter.register_unstructure_hook(date, lambda v: v.isoformat()) converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v)) - converter.register_unstructure_hook_func(is_primitive_enum, identity) + converter.register_unstructure_hook_factory(is_primitive_enum, lambda t: identity) converter.register_unstructure_hook_factory( is_literal_containing_enums, literals_with_enums_unstructure_factory ) diff --git a/server/libs/cattrs/preconf/json.py b/server/libs/cattrs/preconf/json.py index 199c574..355e661 100644 --- a/server/libs/cattrs/preconf/json.py +++ b/server/libs/cattrs/preconf/json.py @@ -52,7 +52,7 @@ def configure_converter(converter: BaseConverter) -> None: converter.register_unstructure_hook_factory( is_literal_containing_enums, literals_with_enums_unstructure_factory ) - converter.register_unstructure_hook_func(is_primitive_enum, identity) + converter.register_unstructure_hook_factory(is_primitive_enum, lambda _: identity) configure_union_passthrough(Union[str, bool, int, float, None], converter) diff --git a/server/libs/cattrs/preconf/msgpack.py b/server/libs/cattrs/preconf/msgpack.py index 9287641..b0726da 100644 --- a/server/libs/cattrs/preconf/msgpack.py +++ b/server/libs/cattrs/preconf/msgpack.py @@ -46,7 +46,7 @@ def configure_converter(converter: BaseConverter) -> None: converter.register_structure_hook( date, lambda v, _: datetime.fromtimestamp(v, timezone.utc).date() ) - converter.register_unstructure_hook_func(is_primitive_enum, identity) + converter.register_unstructure_hook_factory(is_primitive_enum, lambda t: identity) converter.register_unstructure_hook_factory( is_literal_containing_enums, literals_with_enums_unstructure_factory ) diff --git a/server/libs/cattrs/preconf/msgspec.py b/server/libs/cattrs/preconf/msgspec.py index 6274a32..123e277 100644 --- a/server/libs/cattrs/preconf/msgspec.py +++ b/server/libs/cattrs/preconf/msgspec.py @@ -3,18 +3,27 @@ from __future__ import annotations from base64 import b64decode +from collections.abc import Callable from dataclasses import is_dataclass from datetime import date, datetime from enum import Enum from functools import partial -from typing import Any, Callable, TypeVar, Union, get_type_hints +from typing import Any, TypeVar, Union, get_type_hints from attrs import has as attrs_has from attrs import resolve_types from msgspec import Struct, convert, to_builtins from msgspec.json import Encoder, decode -from .._compat import fields, get_args, get_origin, is_bare, is_mapping, is_sequence +from .._compat import ( + fields, + get_args, + get_origin, + is_bare, + is_mapping, + is_sequence, + is_subclass, +) from ..cols import is_namedtuple from ..converters import BaseConverter, Converter from ..dispatch import UnstructureHook @@ -74,7 +83,9 @@ def configure_converter(converter: Converter) -> None: configure_passthroughs(converter) converter.register_unstructure_hook(Struct, to_builtins) - converter.register_unstructure_hook(Enum, identity) + converter.register_unstructure_hook_factory( + lambda t: is_subclass(t, Enum), lambda t, c: identity + ) converter.register_structure_hook(Struct, convert) converter.register_structure_hook(bytes, lambda v, _: b64decode(v)) diff --git a/server/libs/cattrs/preconf/orjson.py b/server/libs/cattrs/preconf/orjson.py index 0726ef0..88ba79a 100644 --- a/server/libs/cattrs/preconf/orjson.py +++ b/server/libs/cattrs/preconf/orjson.py @@ -87,8 +87,8 @@ def configure_converter(converter: Converter) -> None: ), ] ) - converter.register_unstructure_hook_func( - partial(is_primitive_enum, include_bare_enums=True), identity + converter.register_unstructure_hook_factory( + partial(is_primitive_enum, include_bare_enums=True), lambda t: identity ) converter.register_unstructure_hook_factory( is_literal_containing_enums, literals_with_enums_unstructure_factory diff --git a/server/libs/cattrs/preconf/tomlkit.py b/server/libs/cattrs/preconf/tomlkit.py index 802df9b..f497e6a 100644 --- a/server/libs/cattrs/preconf/tomlkit.py +++ b/server/libs/cattrs/preconf/tomlkit.py @@ -12,6 +12,7 @@ from tomlkit.items import Float, Integer, String from .._compat import is_mapping, is_subclass from ..converters import BaseConverter, Converter +from ..fns import identity from ..strategies import configure_union_passthrough from . import validate_datetime, wrap @@ -37,6 +38,9 @@ def configure_converter(converter: BaseConverter): * sets are serialized as lists * tuples are serializas as lists * mapping keys are coerced into strings when unstructuring + + .. versionchanged:: 26.1.0 + date objects are now passed through to tomlkit without unstructuring. """ converter.register_structure_hook(bytes, lambda v, _: b85decode(v)) converter.register_unstructure_hook( @@ -67,10 +71,12 @@ def configure_converter(converter: BaseConverter): # datetime inherits from date, so identity unstructure hook used # here to prevent the date unstructure hook running. - converter.register_unstructure_hook(datetime, lambda v: v) + converter.register_unstructure_hook(datetime, identity) converter.register_structure_hook(datetime, validate_datetime) - converter.register_unstructure_hook(date, lambda v: v.isoformat()) - converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v)) + converter.register_unstructure_hook(date, identity) + converter.register_structure_hook( + date, lambda v, _: v if isinstance(v, date) else date.fromisoformat(v) + ) configure_union_passthrough( Union[str, String, bool, int, Integer, float, Float], converter ) diff --git a/server/libs/cattrs/preconf/tomllib.py b/server/libs/cattrs/preconf/tomllib.py new file mode 100644 index 0000000..2a264ed --- /dev/null +++ b/server/libs/cattrs/preconf/tomllib.py @@ -0,0 +1,94 @@ +"""Preconfigured converters for tomllib.""" + +from base64 import b85decode, b85encode +from collections.abc import Set +from datetime import date, datetime +from enum import Enum +from operator import attrgetter +from typing import Any, TypeVar, Union + +try: + from tomllib import loads +except ImportError: + from tomli import loads + +try: + from tomli_w import dumps +except ImportError: # pragma: nocover + dumps = None + +from .._compat import is_mapping, is_subclass +from ..converters import BaseConverter, Converter +from ..fns import identity +from ..strategies import configure_union_passthrough +from . import validate_datetime, wrap + +__all__ = ["TomllibConverter", "configure_converter", "make_converter"] + +T = TypeVar("T") +_enum_value_getter = attrgetter("_value_") + + +class TomllibConverter(Converter): + """A converter subclass specialized for tomllib.""" + + if dumps is not None: + + def dumps(self, obj: Any, unstructure_as: Any = None, **kwargs: Any) -> str: + return dumps(self.unstructure(obj, unstructure_as=unstructure_as), **kwargs) + + def loads(self, data: str, cl: type[T], **kwargs: Any) -> T: + return self.structure(loads(data, **kwargs), cl) + + +def configure_converter(converter: BaseConverter): + """ + Configure the converter for use with the tomllib library. + + * bytes are serialized as base85 strings + * sets are serialized as lists + * tuples are serializas as lists + * mapping keys are coerced into strings when unstructuring + * dates and datetimes are left for tomllib to handle + """ + converter.register_structure_hook(bytes, lambda v, _: b85decode(v)) + converter.register_unstructure_hook( + bytes, lambda v: (b85encode(v) if v else b"").decode("utf8") + ) + + @converter.register_unstructure_hook_factory(is_mapping) + def gen_unstructure_mapping(cl: Any, unstructure_to=None): + key_handler = str + args = getattr(cl, "__args__", None) + if args: + if is_subclass(args[0], str): + key_handler = _enum_value_getter if is_subclass(args[0], Enum) else None + elif is_subclass(args[0], bytes): + + def key_handler(k: bytes): + return b85encode(k).decode("utf8") + + return converter.gen_unstructure_mapping( + cl, unstructure_to=unstructure_to, key_handler=key_handler + ) + + converter.register_unstructure_hook(datetime, identity) + converter.register_structure_hook(datetime, validate_datetime) + converter.register_unstructure_hook(date, identity) + converter.register_structure_hook( + date, lambda v, _: v if isinstance(v, date) else date.fromisoformat(v) + ) + configure_union_passthrough(Union[str, int, float, bool], converter) + + +@wrap(TomllibConverter) +def make_converter(*args: Any, **kwargs: Any) -> TomllibConverter: + kwargs["unstruct_collection_overrides"] = { + Set: list, + tuple: list, + **kwargs.get("unstruct_collection_overrides", {}), + } + res = TomllibConverter(*args, **kwargs) + configure_converter(res) + + return res diff --git a/server/libs/cattrs/preconf/ujson.py b/server/libs/cattrs/preconf/ujson.py index 8f33061..6cb3665 100644 --- a/server/libs/cattrs/preconf/ujson.py +++ b/server/libs/cattrs/preconf/ujson.py @@ -47,7 +47,7 @@ def configure_converter(converter: BaseConverter): converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v)) converter.register_unstructure_hook(date, lambda v: v.isoformat()) converter.register_structure_hook(date, lambda v, _: date.fromisoformat(v)) - converter.register_unstructure_hook_func(is_primitive_enum, identity) + converter.register_unstructure_hook_factory(is_primitive_enum, lambda t: identity) converter.register_unstructure_hook_factory( is_literal_containing_enums, literals_with_enums_unstructure_factory ) diff --git a/server/libs/cattrs/strategies/_subclasses.py b/server/libs/cattrs/strategies/_subclasses.py index 483a226..695b011 100644 --- a/server/libs/cattrs/strategies/_subclasses.py +++ b/server/libs/cattrs/strategies/_subclasses.py @@ -9,21 +9,25 @@ from typing import Any, Callable, TypeVar, Union from ..converters import BaseConverter from ..gen import AttributeOverride, make_dict_structure_fn, make_dict_unstructure_fn from ..gen._consts import already_generating +from ..subclasses import subclasses def _make_subclasses_tree(cl: type) -> list[type]: # get class origin for accessing subclasses (see #648 for more info) cls_origin = typing.get_origin(cl) or cl - return [cl] + [ - sscl - for scl in cls_origin.__subclasses__() - for sscl in _make_subclasses_tree(scl) - ] + + # Use a dict to deduplicate and keep insertion order. + seen = {cl: None} + for scl in subclasses(cls_origin): + for sscl in _make_subclasses_tree(scl): + seen[sscl] = None + return list(seen) def _has_subclasses(cl: type, given_subclasses: tuple[type, ...]) -> bool: """Whether the given class has subclasses from `given_subclasses`.""" - actual = set(cl.__subclasses__()) + cls_origin = typing.get_origin(cl) or cl + actual = set(subclasses(cls_origin)) given = set(given_subclasses) return bool(actual & given) @@ -68,6 +72,9 @@ def include_subclasses( .. versionchanged:: 24.1.0 When overrides are not provided, hooks for individual classes are retrieved from the converter instead of generated with no overrides, using converter defaults. + .. versionchanged:: 25.2.0 + Slotted dataclasses work on Python 3.14 via :func:`cattrs.subclasses.subclasses`, + which filters out duplicate classes caused by slotting. """ # Due to https://github.com/python-attrs/attrs/issues/1047 collect() @@ -231,7 +238,13 @@ def _include_subclasses_with_union_strategy( return cls is _cl converter.register_unstructure_hook_func(cls_is_cl, unstruct_hook) - subclasses = tuple([c for c in union_classes if issubclass(c, cl)]) + subclasses = tuple( + [ + c + for c in union_classes + if issubclass(typing.get_origin(c) or c, typing.get_origin(cl) or cl) + ] + ) if len(subclasses) > 1: u = Union[subclasses] # type: ignore union_strategy(u, converter) diff --git a/server/libs/cattrs/strategies/_unions.py b/server/libs/cattrs/strategies/_unions.py index 57e132d..14add98 100644 --- a/server/libs/cattrs/strategies/_unions.py +++ b/server/libs/cattrs/strategies/_unions.py @@ -52,23 +52,10 @@ def configure_tagged_union( if is_type_alias(union): union = union.__value__ args = union.__args__ + tag_to_hook = {} exact_cl_unstruct_hooks = {} - for cl in args: - tag = tag_generator(cl) - struct_handler = converter.get_structure_hook(cl) - unstruct_handler = converter.get_unstructure_hook(cl) - - def structure_union_member(val: dict, _cl=cl, _h=struct_handler) -> cl: - return _h(val, _cl) - - def unstructure_union_member(val: union, _h=unstruct_handler) -> dict: - return _h(val) - - tag_to_hook[tag] = structure_union_member - exact_cl_unstruct_hooks[cl] = unstructure_union_member - - cl_to_tag = {cl: tag_generator(cl) for cl in args} + cl_to_tag = {} if default is not NOTHING: default_handler = converter.get_structure_hook(default) @@ -76,36 +63,9 @@ def configure_tagged_union( def structure_default(val: dict, _cl=default, _h=default_handler): return _h(val, _cl) - tag_to_hook = defaultdict(lambda: structure_default, tag_to_hook) - cl_to_tag = defaultdict(lambda: default, cl_to_tag) + tag_to_hook = defaultdict(lambda: structure_default) + cl_to_tag = defaultdict(lambda: default) - def unstructure_tagged_union( - val: union, - _exact_cl_unstruct_hooks=exact_cl_unstruct_hooks, - _cl_to_tag=cl_to_tag, - _tag_name=tag_name, - ) -> dict: - res = _exact_cl_unstruct_hooks[val.__class__](val) - res[_tag_name] = _cl_to_tag[val.__class__] - return res - - if default is NOTHING: - if getattr(converter, "forbid_extra_keys", False): - - def structure_tagged_union( - val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name - ) -> union: - val = val.copy() - return _tag_to_cl[val.pop(_tag_name)](val) - - else: - - def structure_tagged_union( - val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name - ) -> union: - return _tag_to_cl[val[_tag_name]](val) - - else: if getattr(converter, "forbid_extra_keys", False): def structure_tagged_union( @@ -135,11 +95,54 @@ def configure_tagged_union( return _tag_to_hook[val[_tag_name]](val) return _dh(val, _default) + else: + if getattr(converter, "forbid_extra_keys", False): + + def structure_tagged_union( + val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name + ) -> union: + val = val.copy() + return _tag_to_cl[val.pop(_tag_name)](val) + + else: + + def structure_tagged_union( + val: dict, _, _tag_to_cl=tag_to_hook, _tag_name=tag_name + ) -> union: + return _tag_to_cl[val[_tag_name]](val) + + def unstructure_tagged_union( + val: union, + _exact_cl_unstruct_hooks=exact_cl_unstruct_hooks, + _cl_to_tag=cl_to_tag, + _tag_name=tag_name, + ) -> dict: + res = _exact_cl_unstruct_hooks[val.__class__](val) + res[_tag_name] = _cl_to_tag[val.__class__] + return res + converter.register_unstructure_hook(union, unstructure_tagged_union) converter.register_structure_hook(union, structure_tagged_union) + for cl in args: + tag = tag_generator(cl) + struct_handler = converter.get_structure_hook(cl) + unstruct_handler = converter.get_unstructure_hook(cl) -def configure_union_passthrough(union: Any, converter: BaseConverter) -> None: + def structure_union_member(val: dict, _cl=cl, _h=struct_handler) -> cl: + return _h(val, _cl) + + def unstructure_union_member(val: union, _h=unstruct_handler) -> dict: + return _h(val) + + tag_to_hook[tag] = structure_union_member + exact_cl_unstruct_hooks[cl] = unstructure_union_member + cl_to_tag[cl] = tag + + +def configure_union_passthrough( + union: Any, converter: BaseConverter, accept_ints_as_floats: bool = True +) -> None: """ Configure the converter to support validating and passing through unions of the provided types and their subsets. @@ -162,7 +165,14 @@ def configure_union_passthrough(union: Any, converter: BaseConverter) -> None: If the union contains a class and one or more of its subclasses, the subclasses will also be included when validating the superclass. + :param accept_ints_as_floats: When set (the default), if the provided union + contains both ints and floats, actual unions containing only floats will also accept + ints. See https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex + for more information. + .. versionadded:: 23.2.0 + .. versionchanged:: 25.2.0 + Introduced the `accept_ints_as_floats` parameter. """ args = set(union.__args__) @@ -205,6 +215,16 @@ def configure_union_passthrough(union: Any, converter: BaseConverter) -> None: and not is_literal(a) } + # By default, when floats are part of the union, accept ints too. + if ( + accept_ints_as_floats + and int in args + and float in args + and float in non_literal_classes + and int not in non_literal_classes + ): + non_literal_classes.add(int) + if spillover: spillover_type = ( Union[tuple(spillover)] if len(spillover) > 1 else next(iter(spillover)) diff --git a/server/libs/cattrs/subclasses.py b/server/libs/cattrs/subclasses.py new file mode 100644 index 0000000..6c6164e --- /dev/null +++ b/server/libs/cattrs/subclasses.py @@ -0,0 +1,27 @@ +import sys + +if sys.version_info <= (3, 13): + + def subclasses(cls: type) -> list[type]: + """A proxy for `cls.__subclasses__()` on older Pythons.""" + return cls.__subclasses__() + +else: + + def subclasses(cls: type) -> list[type]: + """A helper for getting subclasses of a class. + + Filters out duplicate subclasses of slot dataclasses and attrs classes. + """ + return [ + cl + for cl in cls.__subclasses__() + if ( + not ( + "__slots__" not in cl.__dict__ + and hasattr(cls, "__dataclass_params__") + and cls.__dataclass_params__.slots + ) + and not hasattr(cls, "__attrs_base_of_slotted__") + ) + ] diff --git a/server/libs/exceptiongroup.py b/server/libs/exceptiongroup.py new file mode 100644 index 0000000..ea74544 --- /dev/null +++ b/server/libs/exceptiongroup.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +class ExceptionGroup(Exception): + """Minimal backport used by bundled libs on Python < 3.11.""" + + def __new__(cls, message, exceptions): + obj = super().__new__(cls, message) + obj.message = message + obj.exceptions = tuple(exceptions) + return obj + + def __init__(self, message, exceptions): + super().__init__(message) + self.message = message + self.exceptions = tuple(exceptions) + + def derive(self, exceptions): + return self.__class__(self.message, exceptions) diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/INSTALLER b/server/libs/importlib_metadata-6.8.0.dist-info/INSTALLER index a1b589e..5c69047 100644 --- a/server/libs/importlib_metadata-6.8.0.dist-info/INSTALLER +++ b/server/libs/importlib_metadata-6.8.0.dist-info/INSTALLER @@ -1 +1 @@ -pip +uv \ No newline at end of file diff --git a/server/libs/importlib_metadata-6.8.0.dist-info/RECORD b/server/libs/importlib_metadata-6.8.0.dist-info/RECORD index 9e6c0a6..9c66696 100644 --- a/server/libs/importlib_metadata-6.8.0.dist-info/RECORD +++ b/server/libs/importlib_metadata-6.8.0.dist-info/RECORD @@ -1,4 +1,4 @@ -importlib_metadata-6.8.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +importlib_metadata-6.8.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2 importlib_metadata-6.8.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358 importlib_metadata-6.8.0.dist-info/METADATA,sha256=X79qGRh7gqvuaL_utK5X-MnwHJuIWke0e3eAx0IiLhc,5067 importlib_metadata-6.8.0.dist-info/RECORD,, @@ -6,15 +6,6 @@ importlib_metadata-6.8.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk importlib_metadata-6.8.0.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92 importlib_metadata-6.8.0.dist-info/top_level.txt,sha256=CO3fD9yylANiXkrMo4qHLV_mqXL2sC5JFKgt1yWAT-A,19 importlib_metadata/__init__.py,sha256=EiH0qTKP_6oa6pRGJgPrq0kvjnL3hJ18BJH8VaAYSBA,30749 -importlib_metadata/__pycache__/__init__.cpython-311.pyc,, -importlib_metadata/__pycache__/_adapters.cpython-311.pyc,, -importlib_metadata/__pycache__/_collections.cpython-311.pyc,, -importlib_metadata/__pycache__/_compat.cpython-311.pyc,, -importlib_metadata/__pycache__/_functools.cpython-311.pyc,, -importlib_metadata/__pycache__/_itertools.cpython-311.pyc,, -importlib_metadata/__pycache__/_meta.cpython-311.pyc,, -importlib_metadata/__pycache__/_py39compat.cpython-311.pyc,, -importlib_metadata/__pycache__/_text.cpython-311.pyc,, importlib_metadata/_adapters.py,sha256=i8S6Ib1OQjcILA-l4gkzktMZe18TaeUNI49PLRp6OBU,2454 importlib_metadata/_collections.py,sha256=CJ0OTCHIjWA0ZIVS4voORAsn2R4R2cQBEtPsZEJpASY,743 importlib_metadata/_compat.py,sha256=zhjcWMfA9SNExFVVVBozOYbuiok0A4tdMsNk9ZDZi-A,1554 diff --git a/server/libs/lsprotocol-2023.0.1.dist-info/INSTALLER b/server/libs/lsprotocol-2023.0.1.dist-info/INSTALLER index a1b589e..5c69047 100644 --- a/server/libs/lsprotocol-2023.0.1.dist-info/INSTALLER +++ b/server/libs/lsprotocol-2023.0.1.dist-info/INSTALLER @@ -1 +1 @@ -pip +uv \ No newline at end of file diff --git a/server/libs/lsprotocol-2023.0.1.dist-info/RECORD b/server/libs/lsprotocol-2023.0.1.dist-info/RECORD index 7edecce..c64c566 100644 --- a/server/libs/lsprotocol-2023.0.1.dist-info/RECORD +++ b/server/libs/lsprotocol-2023.0.1.dist-info/RECORD @@ -1,15 +1,10 @@ -lsprotocol-2023.0.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +lsprotocol-2023.0.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2 lsprotocol-2023.0.1.dist-info/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141 lsprotocol-2023.0.1.dist-info/METADATA,sha256=oh7M_V0nCX-lx8MCik5z0_J8Wyd7ApJtdl30wWs4Tb8,2237 lsprotocol-2023.0.1.dist-info/RECORD,, lsprotocol-2023.0.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 lsprotocol-2023.0.1.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81 lsprotocol/__init__.py,sha256=zoT6Do2JtGHGb7pOeKpahg4ocXIsSpyowjhOrhUhx8g,94 -lsprotocol/__pycache__/__init__.cpython-311.pyc,, -lsprotocol/__pycache__/_hooks.cpython-311.pyc,, -lsprotocol/__pycache__/converters.cpython-311.pyc,, -lsprotocol/__pycache__/types.cpython-311.pyc,, -lsprotocol/__pycache__/validators.cpython-311.pyc,, lsprotocol/_hooks.py,sha256=PCTq4Ve_dDd02DMcWQ8afu9gj_oyX0B4nDOomPghqYs,41570 lsprotocol/converters.py,sha256=404tQOVoZL31R9CBrDe6Gx9Nok5cRph3glKlnSx00fo,433 lsprotocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/server/libs/packaging-25.0.dist-info/INSTALLER b/server/libs/packaging-25.0.dist-info/INSTALLER deleted file mode 100644 index a1b589e..0000000 --- a/server/libs/packaging-25.0.dist-info/INSTALLER +++ /dev/null @@ -1 +0,0 @@ -pip diff --git a/server/libs/packaging-25.0.dist-info/RECORD b/server/libs/packaging-25.0.dist-info/RECORD deleted file mode 100644 index 9346bb4..0000000 --- a/server/libs/packaging-25.0.dist-info/RECORD +++ /dev/null @@ -1,41 +0,0 @@ -packaging-25.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -packaging-25.0.dist-info/METADATA,sha256=W2EaYJw4_vw9YWv0XSCuyY-31T8kXayp4sMPyFx6woI,3281 -packaging-25.0.dist-info/RECORD,, -packaging-25.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -packaging-25.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82 -packaging-25.0.dist-info/licenses/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197 -packaging-25.0.dist-info/licenses/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174 -packaging-25.0.dist-info/licenses/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344 -packaging/__init__.py,sha256=_0cDiPVf2S-bNfVmZguxxzmrIYWlyASxpqph4qsJWUc,494 -packaging/__pycache__/__init__.cpython-311.pyc,, -packaging/__pycache__/_elffile.cpython-311.pyc,, -packaging/__pycache__/_manylinux.cpython-311.pyc,, -packaging/__pycache__/_musllinux.cpython-311.pyc,, -packaging/__pycache__/_parser.cpython-311.pyc,, -packaging/__pycache__/_structures.cpython-311.pyc,, -packaging/__pycache__/_tokenizer.cpython-311.pyc,, -packaging/__pycache__/markers.cpython-311.pyc,, -packaging/__pycache__/metadata.cpython-311.pyc,, -packaging/__pycache__/requirements.cpython-311.pyc,, -packaging/__pycache__/specifiers.cpython-311.pyc,, -packaging/__pycache__/tags.cpython-311.pyc,, -packaging/__pycache__/utils.cpython-311.pyc,, -packaging/__pycache__/version.cpython-311.pyc,, -packaging/_elffile.py,sha256=UkrbDtW7aeq3qqoAfU16ojyHZ1xsTvGke_WqMTKAKd0,3286 -packaging/_manylinux.py,sha256=t4y_-dTOcfr36gLY-ztiOpxxJFGO2ikC11HgfysGxiM,9596 -packaging/_musllinux.py,sha256=p9ZqNYiOItGee8KcZFeHF_YcdhVwGHdK6r-8lgixvGQ,2694 -packaging/_parser.py,sha256=gYfnj0pRHflVc4RHZit13KNTyN9iiVcU2RUCGi22BwM,10221 -packaging/_structures.py,sha256=q3eVNmbWJGG_S0Dit_S3Ao8qQqz_5PYTXFAKBZe5yr4,1431 -packaging/_tokenizer.py,sha256=OYzt7qKxylOAJ-q0XyK1qAycyPRYLfMPdGQKRXkZWyI,5310 -packaging/licenses/__init__.py,sha256=VsK4o27CJXWfTi8r2ybJmsBoCdhpnBWuNrskaCVKP7U,5715 -packaging/licenses/__pycache__/__init__.cpython-311.pyc,, -packaging/licenses/__pycache__/_spdx.cpython-311.pyc,, -packaging/licenses/_spdx.py,sha256=oAm1ztPFwlsmCKe7lAAsv_OIOfS1cWDu9bNBkeu-2ns,48398 -packaging/markers.py,sha256=P0we27jm1xUzgGMJxBjtUFCIWeBxTsMeJTOJ6chZmAY,12049 -packaging/metadata.py,sha256=8IZErqQQnNm53dZZuYq4FGU4_dpyinMeH1QFBIWIkfE,34739 -packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -packaging/requirements.py,sha256=gYyRSAdbrIyKDY66ugIDUQjRMvxkH2ALioTmX3tnL6o,2947 -packaging/specifiers.py,sha256=gtPu5DTc-F9baLq3FTGEK6dPhHGCuwwZetaY0PSV2gs,40055 -packaging/tags.py,sha256=41s97W9Zatrq2Ed7Rc3qeBDaHe8pKKvYq2mGjwahfXk,22745 -packaging/utils.py,sha256=0F3Hh9OFuRgrhTgGZUl5K22Fv1YP2tZl1z_2gO6kJiA,5050 -packaging/version.py,sha256=olfyuk_DPbflNkJ4wBWetXQ17c74x3DB501degUv7DY,16676 diff --git a/server/libs/packaging-26.2.dist-info/INSTALLER b/server/libs/packaging-26.2.dist-info/INSTALLER new file mode 100644 index 0000000..5c69047 --- /dev/null +++ b/server/libs/packaging-26.2.dist-info/INSTALLER @@ -0,0 +1 @@ +uv \ No newline at end of file diff --git a/server/libs/packaging-25.0.dist-info/METADATA b/server/libs/packaging-26.2.dist-info/METADATA similarity index 85% rename from server/libs/packaging-25.0.dist-info/METADATA rename to server/libs/packaging-26.2.dist-info/METADATA index 10b290a..d7ca456 100644 --- a/server/libs/packaging-25.0.dist-info/METADATA +++ b/server/libs/packaging-26.2.dist-info/METADATA @@ -1,14 +1,13 @@ Metadata-Version: 2.4 Name: packaging -Version: 25.0 +Version: 26.2 Summary: Core utilities for Python packages Author-email: Donald Stufft Requires-Python: >=3.8 Description-Content-Type: text/x-rst +License-Expression: Apache-2.0 OR BSD-2-Clause Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved :: Apache Software License -Classifier: License :: OSI Approved :: BSD License Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only @@ -18,8 +17,10 @@ Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Programming Language :: Python :: Free Threading :: 4 - Resilient Classifier: Typing :: Typed License-File: LICENSE License-File: LICENSE.APACHE @@ -42,7 +43,7 @@ or benefit greatly from having a single shared implementation (eg: :pep:`425`). .. end-intro The ``packaging`` project includes the following: version handling, specifiers, -markers, requirements, tags, utilities. +markers, requirements, tags, metadata, lockfiles, utilities. Documentation ------------- @@ -52,8 +53,14 @@ The `documentation`_ provides information and the API for the following: - Version Handling - Specifiers - Markers +- Licenses - Requirements +- Metadata - Tags +- Lockfiles (pylock) +- Direct URL helpers +- Dependency groups +- Errors - Utilities Installation @@ -70,11 +77,11 @@ Discussion If you run into bugs, you can file them in our `issue tracker`_. -You can also join ``#pypa`` on Freenode to ask questions or get involved. - +You can also join discussions on `GitHub Discussions`_ to ask questions or get involved. .. _`documentation`: https://packaging.pypa.io/ .. _`issue tracker`: https://github.com/pypa/packaging/issues +.. _`GitHub Discussions`: https://github.com/pypa/packaging/discussions Code of Conduct diff --git a/server/libs/packaging-26.2.dist-info/RECORD b/server/libs/packaging-26.2.dist-info/RECORD new file mode 100644 index 0000000..e84f245 --- /dev/null +++ b/server/libs/packaging-26.2.dist-info/RECORD @@ -0,0 +1,29 @@ +packaging-26.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2 +packaging-26.2.dist-info/METADATA,sha256=T5y815M0FaR5P3dnyYoralEsgj_IHIczeBVwXyMOyr8,3543 +packaging-26.2.dist-info/RECORD,, +packaging-26.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +packaging-26.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82 +packaging-26.2.dist-info/licenses/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197 +packaging-26.2.dist-info/licenses/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174 +packaging-26.2.dist-info/licenses/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344 +packaging/__init__.py,sha256=QhMEdPu2XogrJzV3S0KWS6t7l0I9k8EeDRJl4fnw87s,494 +packaging/_elffile.py,sha256=-sKkptYqzYw2-x3QByJa5mB4rfPWu1pxkZHRx1WAFCY,3211 +packaging/_manylinux.py,sha256=Hf6nB0cOrayEs96-p3oIXAgGnFquv20DO5l-o2_Xnv0,9559 +packaging/_musllinux.py,sha256=Z6swjH3MA7XS3qXnmMN7QPhqP3fnoYI0eQ18e9-HgAE,2707 +packaging/_parser.py,sha256=Kf2nsDw4c54X82pY8ba4F02Bve6OygGMAjL-Begqcew,11698 +packaging/_structures.py,sha256=60jRbF78p8z5MKnNd6cAprgOadCJHV0DlmUmRBqFZcs,1109 +packaging/_tokenizer.py,sha256=tFU2Wr-ZZJdAbkXLEJo7qUQDJaIkfft9DqaifiEND7A,5391 +packaging/dependency_groups.py,sha256=XZIAVFK9uHG4RCGprmJn3VInUWMesxha_kytJuMO9eY,10218 +packaging/direct_url.py,sha256=eKmbDiPP1sLV4Mj_kCSZqqknrIyVO9Sr7JpF8KCjp4U,10917 +packaging/errors.py,sha256=6hfEYXAf8v_IF65-lFadJOMIieBP2xIKtyEXjG1nGIs,2680 +packaging/licenses/__init__.py,sha256=_Jx0XRiD_58palsWnyLrLuh59ZpGCPIPXLKdZo9OJvQ,7293 +packaging/licenses/_spdx.py,sha256=WW7DXiyg68up_YND_wpRYlr1SHhiV4FfJLQffghhMxQ,51122 +packaging/markers.py,sha256=8fDIUhAF6YMnCNB5FSiwh9pEIusiFzAF73J-0OB8bTk,17055 +packaging/metadata.py,sha256=crAh0E3GVGVqPlu6EdRFsaG-Y6UYznTUqjuGKRGPv6c,38770 +packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +packaging/pylock.py,sha256=G_1gncTmDbRLY1jo4VDI9Uw-b5IErh_Q9V_BbVJTmD8,33890 +packaging/requirements.py,sha256=dd1c9aa1gp5NI6btF6UFRQjPn1nxQXnE_T34yDDTEpc,4383 +packaging/specifiers.py,sha256=Mfp8avQg0lVot17to9lVKBtZD1FsWBTItoGwFUZ3wtg,71514 +packaging/tags.py,sha256=NQ1weo69_Sjte3xBZ1I_G63CIgCmaN0C24mz-z3hGYo,34224 +packaging/utils.py,sha256=M7-JMKic2sP1YtV_8aW7eVGB-x3ADuKCiSrsVeCd2Uo,9848 +packaging/version.py,sha256=Y1aTtxe3sn2xOMa5BdI85-AcHuybbanOVkEvvSRRC8I,38369 diff --git a/server/libs/tclint-0.6.0.dist-info/REQUESTED b/server/libs/packaging-26.2.dist-info/REQUESTED similarity index 100% rename from server/libs/tclint-0.6.0.dist-info/REQUESTED rename to server/libs/packaging-26.2.dist-info/REQUESTED diff --git a/server/libs/packaging-25.0.dist-info/WHEEL b/server/libs/packaging-26.2.dist-info/WHEEL similarity index 100% rename from server/libs/packaging-25.0.dist-info/WHEEL rename to server/libs/packaging-26.2.dist-info/WHEEL diff --git a/server/libs/packaging-25.0.dist-info/licenses/LICENSE b/server/libs/packaging-26.2.dist-info/licenses/LICENSE similarity index 100% rename from server/libs/packaging-25.0.dist-info/licenses/LICENSE rename to server/libs/packaging-26.2.dist-info/licenses/LICENSE diff --git a/server/libs/packaging-25.0.dist-info/licenses/LICENSE.APACHE b/server/libs/packaging-26.2.dist-info/licenses/LICENSE.APACHE similarity index 100% rename from server/libs/packaging-25.0.dist-info/licenses/LICENSE.APACHE rename to server/libs/packaging-26.2.dist-info/licenses/LICENSE.APACHE diff --git a/server/libs/packaging-25.0.dist-info/licenses/LICENSE.BSD b/server/libs/packaging-26.2.dist-info/licenses/LICENSE.BSD similarity index 100% rename from server/libs/packaging-25.0.dist-info/licenses/LICENSE.BSD rename to server/libs/packaging-26.2.dist-info/licenses/LICENSE.BSD diff --git a/server/libs/packaging/__init__.py b/server/libs/packaging/__init__.py index d45c22c..a6bdf59 100644 --- a/server/libs/packaging/__init__.py +++ b/server/libs/packaging/__init__.py @@ -6,7 +6,7 @@ __title__ = "packaging" __summary__ = "Core utilities for Python packages" __uri__ = "https://github.com/pypa/packaging" -__version__ = "25.0" +__version__ = "26.2" __author__ = "Donald Stufft and individual contributors" __email__ = "donald@stufft.io" diff --git a/server/libs/packaging/_elffile.py b/server/libs/packaging/_elffile.py index 7a5afc3..497b064 100644 --- a/server/libs/packaging/_elffile.py +++ b/server/libs/packaging/_elffile.py @@ -4,7 +4,6 @@ ELF file parser. This provides a class ``ELFFile`` that parses an ELF executable in a similar interface to ``ZipFile``. Only the read interface is implemented. -Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html """ diff --git a/server/libs/packaging/_manylinux.py b/server/libs/packaging/_manylinux.py index 95f5576..0e79e8a 100644 --- a/server/libs/packaging/_manylinux.py +++ b/server/libs/packaging/_manylinux.py @@ -15,6 +15,16 @@ EF_ARM_ABIMASK = 0xFF000000 EF_ARM_ABI_VER5 = 0x05000000 EF_ARM_ABI_FLOAT_HARD = 0x00000400 +_ALLOWED_ARCHS = { + "x86_64", + "aarch64", + "ppc64", + "ppc64le", + "s390x", + "loongarch64", + "riscv64", +} + # `os.PathLike` not a generic type until Python 3.9, so sticking with `str` # as the type for `path` until then. @@ -57,16 +67,7 @@ def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool: return _is_linux_armhf(executable) if "i686" in archs: return _is_linux_i686(executable) - allowed_archs = { - "x86_64", - "aarch64", - "ppc64", - "ppc64le", - "s390x", - "loongarch64", - "riscv64", - } - return any(arch in allowed_archs for arch in archs) + return any(arch in _ALLOWED_ARCHS for arch in archs) # If glibc ever changes its major version, we need to know what the last @@ -106,7 +107,7 @@ def _glibc_version_string_ctypes() -> str | None: Fallback implementation of glibc_version_string using ctypes. """ try: - import ctypes + import ctypes # noqa: PLC0415 except ImportError: return None @@ -150,7 +151,7 @@ def _glibc_version_string() -> str | None: return _glibc_version_string_confstr() or _glibc_version_string_ctypes() -def _parse_glibc_version(version_str: str) -> tuple[int, int]: +def _parse_glibc_version(version_str: str) -> _GLibCVersion: """Parse glibc version. We use a regexp instead of str.split because we want to discard any @@ -165,15 +166,15 @@ def _parse_glibc_version(version_str: str) -> tuple[int, int]: RuntimeWarning, stacklevel=2, ) - return -1, -1 - return int(m.group("major")), int(m.group("minor")) + return _GLibCVersion(-1, -1) + return _GLibCVersion(int(m.group("major")), int(m.group("minor"))) @functools.lru_cache -def _get_glibc_version() -> tuple[int, int]: +def _get_glibc_version() -> _GLibCVersion: version_str = _glibc_version_string() if version_str is None: - return (-1, -1) + return _GLibCVersion(-1, -1) return _parse_glibc_version(version_str) @@ -184,7 +185,7 @@ def _is_compatible(arch: str, version: _GLibCVersion) -> bool: return False # Check for presence of _manylinux module. try: - import _manylinux + import _manylinux # noqa: PLC0415 except ImportError: return True if hasattr(_manylinux, "manylinux_compatible"): @@ -192,25 +193,26 @@ def _is_compatible(arch: str, version: _GLibCVersion) -> bool: if result is not None: return bool(result) return True - if version == _GLibCVersion(2, 5): - if hasattr(_manylinux, "manylinux1_compatible"): - return bool(_manylinux.manylinux1_compatible) - if version == _GLibCVersion(2, 12): - if hasattr(_manylinux, "manylinux2010_compatible"): - return bool(_manylinux.manylinux2010_compatible) - if version == _GLibCVersion(2, 17): - if hasattr(_manylinux, "manylinux2014_compatible"): - return bool(_manylinux.manylinux2014_compatible) + if version == _GLibCVersion(2, 5) and hasattr(_manylinux, "manylinux1_compatible"): + return bool(_manylinux.manylinux1_compatible) + if version == _GLibCVersion(2, 12) and hasattr( + _manylinux, "manylinux2010_compatible" + ): + return bool(_manylinux.manylinux2010_compatible) + if version == _GLibCVersion(2, 17) and hasattr( + _manylinux, "manylinux2014_compatible" + ): + return bool(_manylinux.manylinux2014_compatible) return True -_LEGACY_MANYLINUX_MAP = { +_LEGACY_MANYLINUX_MAP: dict[_GLibCVersion, str] = { # CentOS 7 w/ glibc 2.17 (PEP 599) - (2, 17): "manylinux2014", + _GLibCVersion(2, 17): "manylinux2014", # CentOS 6 w/ glibc 2.12 (PEP 571) - (2, 12): "manylinux2010", + _GLibCVersion(2, 12): "manylinux2010", # CentOS 5 w/ glibc 2.5 (PEP 513) - (2, 5): "manylinux1", + _GLibCVersion(2, 5): "manylinux1", } @@ -252,11 +254,9 @@ def platform_tags(archs: Sequence[str]) -> Iterator[str]: min_minor = -1 for glibc_minor in range(glibc_max.minor, min_minor, -1): glibc_version = _GLibCVersion(glibc_max.major, glibc_minor) - tag = "manylinux_{}_{}".format(*glibc_version) if _is_compatible(arch, glibc_version): - yield f"{tag}_{arch}" - # Handle the legacy manylinux1, manylinux2010, manylinux2014 tags. - if glibc_version in _LEGACY_MANYLINUX_MAP: - legacy_tag = _LEGACY_MANYLINUX_MAP[glibc_version] - if _is_compatible(arch, glibc_version): + yield "manylinux_{}_{}_{}".format(*glibc_version, arch) + + # Handle the legacy manylinux1, manylinux2010, manylinux2014 tags. + if legacy_tag := _LEGACY_MANYLINUX_MAP.get(glibc_version): yield f"{legacy_tag}_{arch}" diff --git a/server/libs/packaging/_musllinux.py b/server/libs/packaging/_musllinux.py index d2bf30b..4e8116a 100644 --- a/server/libs/packaging/_musllinux.py +++ b/server/libs/packaging/_musllinux.py @@ -49,7 +49,7 @@ def _get_musl_version(executable: str) -> _MuslVersion | None: return None if ld is None or "musl" not in ld: return None - proc = subprocess.run([ld], stderr=subprocess.PIPE, text=True) + proc = subprocess.run([ld], check=False, stderr=subprocess.PIPE, text=True) return _parse_musl_version(proc.stderr) diff --git a/server/libs/packaging/_parser.py b/server/libs/packaging/_parser.py index 0007c0a..d320269 100644 --- a/server/libs/packaging/_parser.py +++ b/server/libs/packaging/_parser.py @@ -7,12 +7,14 @@ the implementation. from __future__ import annotations import ast -from typing import NamedTuple, Sequence, Tuple, Union +from typing import List, Literal, NamedTuple, Sequence, Tuple, Union from ._tokenizer import DEFAULT_RULES, Tokenizer class Node: + __slots__ = ("value",) + def __init__(self, value: str) -> None: self.value = value @@ -20,31 +22,66 @@ class Node: return self.value def __repr__(self) -> str: - return f"<{self.__class__.__name__}('{self}')>" + return f"<{self.__class__.__name__}({self.value!r})>" def serialize(self) -> str: raise NotImplementedError + def __getstate__(self) -> str: + # Return just the value string for compactness and stability. + return self.value + + def _restore_value(self, value: object) -> None: + if not isinstance(value, str): + raise TypeError( + f"Cannot restore {self.__class__.__name__} value from {value!r}" + ) + self.value = value + + def __setstate__(self, state: object) -> None: + if isinstance(state, str): + # New format (26.2+): just the value string. + self._restore_value(state) + return + if isinstance(state, tuple) and len(state) == 2: + # Old format (packaging <= 26.0, __slots__): (None, {slot: value}). + _, slot_dict = state + if isinstance(slot_dict, dict) and "value" in slot_dict: + self._restore_value(slot_dict["value"]) + return + if isinstance(state, dict) and "value" in state: + # Old format (packaging <= 25.0, no __slots__): plain __dict__. + self._restore_value(state["value"]) + return + raise TypeError(f"Cannot restore {self.__class__.__name__} from {state!r}") + class Variable(Node): + __slots__ = () + def serialize(self) -> str: return str(self) class Value(Node): + __slots__ = () + def serialize(self) -> str: return f'"{self}"' class Op(Node): + __slots__ = () + def serialize(self) -> str: return str(self) +MarkerLogical = Literal["and", "or"] MarkerVar = Union[Variable, Value] MarkerItem = Tuple[MarkerVar, Op, MarkerVar] MarkerAtom = Union[MarkerItem, Sequence["MarkerAtom"]] -MarkerList = Sequence[Union["MarkerList", MarkerAtom, str]] +MarkerList = List[Union["MarkerList", MarkerAtom, MarkerLogical]] class ParsedRequirement(NamedTuple): @@ -111,7 +148,9 @@ def _parse_requirement_details( return (url, specifier, marker) marker = _parse_requirement_marker( - tokenizer, span_start=url_start, after="URL and whitespace" + tokenizer, + span_start=url_start, + expected="semicolon (after URL and whitespace)", ) else: specifier_start = tokenizer.position @@ -124,10 +163,10 @@ def _parse_requirement_details( marker = _parse_requirement_marker( tokenizer, span_start=specifier_start, - after=( - "version specifier" + expected=( + "comma (within version specifier), semicolon (after version specifier)" if specifier - else "name and no valid version specifier" + else "semicolon (after name with no version specifier)" ), ) @@ -135,7 +174,7 @@ def _parse_requirement_details( def _parse_requirement_marker( - tokenizer: Tokenizer, *, span_start: int, after: str + tokenizer: Tokenizer, *, span_start: int, expected: str ) -> MarkerList: """ requirement_marker = SEMICOLON marker WS? @@ -143,8 +182,9 @@ def _parse_requirement_marker( if not tokenizer.check("SEMICOLON"): tokenizer.raise_syntax_error( - f"Expected end or semicolon (after {after})", + f"Expected {expected} or end", span_start=span_start, + span_end=None, ) tokenizer.read() @@ -307,7 +347,7 @@ def _parse_marker_item(tokenizer: Tokenizer) -> MarkerItem: return (marker_var_left, marker_op, marker_var_right) -def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: +def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: # noqa: RET503 """ marker_var = VARIABLE | QUOTED_STRING """ diff --git a/server/libs/packaging/_structures.py b/server/libs/packaging/_structures.py index 90a6465..4306784 100644 --- a/server/libs/packaging/_structures.py +++ b/server/libs/packaging/_structures.py @@ -2,60 +2,32 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +"""Backward-compatibility shim for unpickling Version objects serialized before +packaging 26.1. + +Old pickles reference ``packaging._structures.InfinityType`` and +``packaging._structures.NegativeInfinityType``. This module provides minimal +stand-in classes so that ``pickle.loads()`` can resolve those references. +The deserialized objects are not used for comparisons — ``Version.__setstate__`` +discards the stale ``_key`` cache and recomputes it from the core version fields. +""" + +from __future__ import annotations + class InfinityType: + """Stand-in for the removed ``InfinityType`` used in old comparison keys.""" + def __repr__(self) -> str: return "Infinity" - def __hash__(self) -> int: - return hash(repr(self)) - - def __lt__(self, other: object) -> bool: - return False - - def __le__(self, other: object) -> bool: - return False - - def __eq__(self, other: object) -> bool: - return isinstance(other, self.__class__) - - def __gt__(self, other: object) -> bool: - return True - - def __ge__(self, other: object) -> bool: - return True - - def __neg__(self: object) -> "NegativeInfinityType": - return NegativeInfinity - - -Infinity = InfinityType() - class NegativeInfinityType: + """Stand-in for the removed ``NegativeInfinityType`` used in old comparison keys.""" + def __repr__(self) -> str: return "-Infinity" - def __hash__(self) -> int: - return hash(repr(self)) - - def __lt__(self, other: object) -> bool: - return True - - def __le__(self, other: object) -> bool: - return True - - def __eq__(self, other: object) -> bool: - return isinstance(other, self.__class__) - - def __gt__(self, other: object) -> bool: - return False - - def __ge__(self, other: object) -> bool: - return False - - def __neg__(self: object) -> InfinityType: - return Infinity - +Infinity = InfinityType() NegativeInfinity = NegativeInfinityType() diff --git a/server/libs/packaging/_tokenizer.py b/server/libs/packaging/_tokenizer.py index d28a9b6..5ab891c 100644 --- a/server/libs/packaging/_tokenizer.py +++ b/server/libs/packaging/_tokenizer.py @@ -3,7 +3,7 @@ from __future__ import annotations import contextlib import re from dataclasses import dataclass -from typing import Iterator, NoReturn +from typing import Generator, Mapping, NoReturn from .specifiers import Specifier @@ -33,16 +33,16 @@ class ParserSyntaxError(Exception): def __str__(self) -> str: marker = " " * self.span[0] + "~" * (self.span[1] - self.span[0]) + "^" - return "\n ".join([self.message, self.source, marker]) + return f"{self.message}\n {self.source}\n {marker}" -DEFAULT_RULES: dict[str, str | re.Pattern[str]] = { - "LEFT_PARENTHESIS": r"\(", - "RIGHT_PARENTHESIS": r"\)", - "LEFT_BRACKET": r"\[", - "RIGHT_BRACKET": r"\]", - "SEMICOLON": r";", - "COMMA": r",", +DEFAULT_RULES: dict[str, re.Pattern[str]] = { + "LEFT_PARENTHESIS": re.compile(r"\("), + "RIGHT_PARENTHESIS": re.compile(r"\)"), + "LEFT_BRACKET": re.compile(r"\["), + "RIGHT_BRACKET": re.compile(r"\]"), + "SEMICOLON": re.compile(r";"), + "COMMA": re.compile(r","), "QUOTED_STRING": re.compile( r""" ( @@ -53,10 +53,10 @@ DEFAULT_RULES: dict[str, str | re.Pattern[str]] = { """, re.VERBOSE, ), - "OP": r"(===|==|~=|!=|<=|>=|<|>)", - "BOOLOP": r"\b(or|and)\b", - "IN": r"\bin\b", - "NOT": r"\bnot\b", + "OP": re.compile(r"(===|==|~=|!=|<=|>=|<|>)"), + "BOOLOP": re.compile(r"\b(or|and)\b"), + "IN": re.compile(r"\bin\b"), + "NOT": re.compile(r"\bnot\b"), "VARIABLE": re.compile( r""" \b( @@ -75,16 +75,16 @@ DEFAULT_RULES: dict[str, str | re.Pattern[str]] = { re.VERBOSE, ), "SPECIFIER": re.compile( - Specifier._operator_regex_str + Specifier._version_regex_str, + Specifier._specifier_regex_str, re.VERBOSE | re.IGNORECASE, ), - "AT": r"\@", - "URL": r"[^ \t]+", - "IDENTIFIER": r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b", - "VERSION_PREFIX_TRAIL": r"\.\*", - "VERSION_LOCAL_LABEL_TRAIL": r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*", - "WS": r"[ \t]+", - "END": r"$", + "AT": re.compile(r"\@"), + "URL": re.compile(r"[^ \t]+"), + "IDENTIFIER": re.compile(r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b"), + "VERSION_PREFIX_TRAIL": re.compile(r"\.\*"), + "VERSION_LOCAL_LABEL_TRAIL": re.compile(r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*"), + "WS": re.compile(r"[ \t]+"), + "END": re.compile(r"$"), } @@ -99,12 +99,10 @@ class Tokenizer: self, source: str, *, - rules: dict[str, str | re.Pattern[str]], + rules: Mapping[str, re.Pattern[str]], ) -> None: self.source = source - self.rules: dict[str, re.Pattern[str]] = { - name: re.compile(pattern) for name, pattern in rules.items() - } + self.rules = rules self.next_token: Token | None = None self.position = 0 @@ -174,7 +172,7 @@ class Tokenizer: @contextlib.contextmanager def enclosing_tokens( self, open_token: str, close_token: str, *, around: str - ) -> Iterator[None]: + ) -> Generator[None, None, None]: if self.check(open_token): open_position = self.position self.read() diff --git a/server/libs/packaging/dependency_groups.py b/server/libs/packaging/dependency_groups.py new file mode 100644 index 0000000..413e5cb --- /dev/null +++ b/server/libs/packaging/dependency_groups.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence + +from .errors import _ErrorCollector +from .requirements import Requirement + +__all__ = [ + "CyclicDependencyGroup", + "DependencyGroupInclude", + "DependencyGroupResolver", + "DuplicateGroupNames", + "InvalidDependencyGroupObject", + "resolve_dependency_groups", +] + + +def __dir__() -> list[str]: + return __all__ + + +# ----------- +# Error Types +# ----------- + + +class DuplicateGroupNames(ValueError): + """ + The same dependency groups were defined twice, with different non-normalized names. + """ + + +class CyclicDependencyGroup(ValueError): + """ + The dependency group includes form a cycle. + """ + + def __init__(self, requested_group: str, group: str, include_group: str) -> None: + self.requested_group = requested_group + self.group = group + self.include_group = include_group + + if include_group == group: + reason = f"{group} includes itself" + else: + reason = f"{include_group} -> {group}, {group} -> {include_group}" + super().__init__( + "Cyclic dependency group include while resolving " + f"{requested_group}: {reason}" + ) + + +# in the PEP 735 spec, the tables in dependency group lists were described as +# "Dependency Object Specifiers", but the only defined type of object was a +# "Dependency Group Include" -- hence the naming of this error as "Object" +class InvalidDependencyGroupObject(ValueError): + """ + A member of a dependency group was identified as a dict, but was not in a valid + format. + """ + + +# ------------------------ +# Object Model & Interface +# ------------------------ + + +class DependencyGroupInclude: + __slots__ = ("include_group",) + + def __init__(self, include_group: str) -> None: + """ + Initialize a DependencyGroupInclude. + + :param include_group: The name of the group referred to by this include. + """ + self.include_group = include_group + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.include_group!r})" + + +class DependencyGroupResolver: + """ + A resolver for Dependency Group data. + + This class handles caching, name normalization, cycle detection, and other + parsing requirements. There are only two public methods for exploring the data: + ``lookup()`` and ``resolve()``. + + :param dependency_groups: A mapping, as provided via pyproject + ``[dependency-groups]``. + """ + + def __init__( + self, + dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], + ) -> None: + errors = _ErrorCollector() + + self.dependency_groups = _normalize_group_names(dependency_groups, errors) + + # a map of group names to parsed data + self._parsed_groups: dict[ + str, tuple[Requirement | DependencyGroupInclude, ...] + ] = {} + # a map of group names to their ancestors, used for cycle detection + self._include_graph_ancestors: dict[str, tuple[str, ...]] = {} + # a cache of completed resolutions to Requirement lists + self._resolve_cache: dict[str, tuple[Requirement, ...]] = {} + + errors.finalize("[dependency-groups] data was invalid") + + def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]: + """ + Lookup a group name, returning the parsed dependency data for that group. + This will not resolve includes. + + :param group: the name of the group to lookup + """ + group = _normalize_name(group) + + with _ErrorCollector().on_exit( + f"[dependency-groups] data for {group!r} was malformed" + ) as errors: + return self._parse_group(group, errors) + + def resolve(self, group: str) -> tuple[Requirement, ...]: + """ + Resolve a dependency group to a list of requirements. + + :param group: the name of the group to resolve + """ + group = _normalize_name(group) + + with _ErrorCollector().on_exit( + f"[dependency-groups] data for {group!r} was malformed" + ) as errors: + return self._resolve(group, group, errors) + + def _resolve( + self, group: str, requested_group: str, errors: _ErrorCollector + ) -> tuple[Requirement, ...]: + """ + This is a helper for cached resolution to strings. It preserves the name of the + group which the user initially requested in order to present a clearer error in + the event that a cycle is detected. + + :param group: The normalized name of the group to resolve. + :param requested_group: The group which was used in the original, user-facing + request. + """ + if group in self._resolve_cache: + return self._resolve_cache[group] + + parsed = self._parse_group(group, errors) + + resolved_group = [] + + for item in parsed: + if isinstance(item, Requirement): + resolved_group.append(item) + elif isinstance(item, DependencyGroupInclude): + include_group = _normalize_name(item.include_group) + + # if a group is cyclic, record the error + # otherwise, follow the include_group reference + # + # this allows us to examine all includes in a group, even in the + # presence of errors + if include_group in self._include_graph_ancestors.get(group, ()): + errors.error( + CyclicDependencyGroup( + requested_group, group, item.include_group + ) + ) + else: + self._include_graph_ancestors[include_group] = ( + *self._include_graph_ancestors.get(group, ()), + group, + ) + resolved_group.extend( + self._resolve(include_group, requested_group, errors) + ) + else: # pragma: no cover + raise NotImplementedError( + f"Invalid dependency group item after parse: {item}" + ) + + # in the event that errors were detected, present the group as empty and do not + # cache the result + # this ensures that repeated access to a cyclic group will raise multiple errors + if errors.errors: + return () + + self._resolve_cache[group] = tuple(resolved_group) + return self._resolve_cache[group] + + def _parse_group( + self, group: str, errors: _ErrorCollector + ) -> tuple[Requirement | DependencyGroupInclude, ...]: + # short circuit -- never do the work twice + if group in self._parsed_groups: + return self._parsed_groups[group] + + if group not in self.dependency_groups: + errors.error(LookupError(f"Dependency group '{group}' not found")) + return () + + raw_group = self.dependency_groups[group] + if isinstance(raw_group, str): + errors.error( + TypeError( + f"Dependency group {group!r} contained a string rather than a list." + ) + ) + return () + + if not isinstance(raw_group, Sequence): + errors.error( + TypeError(f"Dependency group {group!r} is not a sequence type.") + ) + return () + + elements: list[Requirement | DependencyGroupInclude] = [] + for item in raw_group: + if isinstance(item, str): + # packaging.requirements.Requirement parsing ensures that this is a + # valid PEP 508 Dependency Specifier + # raises InvalidRequirement on failure + elements.append(Requirement(item)) + elif isinstance(item, Mapping): + if tuple(item.keys()) != ("include-group",): + errors.error( + InvalidDependencyGroupObject( + f"Invalid dependency group item: {item!r}" + ) + ) + else: + include_group = item["include-group"] + elements.append(DependencyGroupInclude(include_group=include_group)) + else: + errors.error(TypeError(f"Invalid dependency group item: {item!r}")) + + self._parsed_groups[group] = tuple(elements) + return self._parsed_groups[group] + + +# -------------------- +# Functional Interface +# -------------------- + + +def resolve_dependency_groups( + dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str +) -> tuple[str, ...]: + """ + Resolve a dependency group to a tuple of requirements, as strings. + + :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table + from ``pyproject.toml`` + :param groups: the name of the group(s) to resolve + """ + resolver = DependencyGroupResolver(dependency_groups) + return tuple(str(r) for group in groups for r in resolver.resolve(group)) + + +# ---------------- +# internal helpers +# ---------------- + + +_NORMALIZE_PATTERN = re.compile(r"[-_.]+") + + +def _normalize_name(name: str) -> str: + return _NORMALIZE_PATTERN.sub("-", name).lower() + + +def _normalize_group_names( + dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], + errors: _ErrorCollector, +) -> dict[str, Sequence[str | Mapping[str, str]]]: + original_names: dict[str, list[str]] = {} + normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {} + + for group_name, value in dependency_groups.items(): + normed_group_name = _normalize_name(group_name) + original_names.setdefault(normed_group_name, []).append(group_name) + normalized_groups[normed_group_name] = value + + for normed_name, names in original_names.items(): + if len(names) > 1: + errors.error( + DuplicateGroupNames( + "Duplicate dependency group names: " + f"{normed_name} ({', '.join(names)})" + ) + ) + + return normalized_groups diff --git a/server/libs/packaging/direct_url.py b/server/libs/packaging/direct_url.py new file mode 100644 index 0000000..5d1c56c --- /dev/null +++ b/server/libs/packaging/direct_url.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import dataclasses +import re +import urllib.parse +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Protocol, TypeVar + +if TYPE_CHECKING: # pragma: no cover + import sys + from collections.abc import Collection + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + +__all__ = [ + "ArchiveInfo", + "DirInfo", + "DirectUrl", + "DirectUrlValidationError", + "VcsInfo", +] + + +def __dir__() -> list[str]: + return __all__ + + +_T = TypeVar("_T") + + +class _FromMappingProtocol(Protocol): # pragma: no cover + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: ... + + +_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol) + + +def _json_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]: + return {key: value for key, value in data if value is not None} + + +def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None: + """Get a value from the dictionary and verify it's the expected type.""" + if (value := d.get(key)) is None: + return None + if not isinstance(value, expected_type): + raise DirectUrlValidationError( + f"Unexpected type {type(value).__name__} " + f"(expected {expected_type.__name__})", + context=key, + ) + return value + + +def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T: + """Get a required value from the dictionary and verify it's the expected type.""" + if (value := _get(d, expected_type, key)) is None: + raise _DirectUrlRequiredKeyError(key) + return value + + +def _get_object( + d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str +) -> _FromMappingProtocolT | None: + """Get a dictionary value from the dictionary and convert it to a dataclass.""" + if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract] + return None + try: + return target_type._from_dict(value) + except Exception as e: + raise DirectUrlValidationError(e, context=key) from e + + +_PEP610_USER_PASS_ENV_VARS_REGEX = re.compile( + r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$" +) + + +def _strip_auth_from_netloc(netloc: str, safe_user_passwords: Collection[str]) -> str: + if "@" not in netloc: + return netloc + user_pass, netloc_no_user_pass = netloc.split("@", 1) + if user_pass in safe_user_passwords: + return netloc + if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass): + return netloc + return netloc_no_user_pass + + +def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str: + """url with user:password part removed unless it is formed with + environment variables as specified in PEP 610, or it is a safe user:password + such as `git`. + """ + parsed_url = urllib.parse.urlsplit(url) + netloc = _strip_auth_from_netloc(parsed_url.netloc, safe_user_passwords) + return urllib.parse.urlunsplit( + ( + parsed_url.scheme, + netloc, + parsed_url.path, + parsed_url.query, + parsed_url.fragment, + ) + ) + + +class DirectUrlValidationError(Exception): + """Raised when when input data is not spec-compliant.""" + + context: str | None = None + message: str + + def __init__( + self, + cause: str | Exception, + *, + context: str | None = None, + ) -> None: + if isinstance(cause, DirectUrlValidationError): + if cause.context: + self.context = ( + f"{context}.{cause.context}" if context else cause.context + ) + else: + self.context = context # pragma: no cover + self.message = cause.message + else: + self.context = context + self.message = str(cause) + + def __str__(self) -> str: + if self.context: + return f"{self.message} in {self.context!r}" + return self.message + + +class _DirectUrlRequiredKeyError(DirectUrlValidationError): + def __init__(self, key: str) -> None: + super().__init__("Missing required value", context=key) + + +@dataclasses.dataclass(frozen=True, init=False) +class VcsInfo: + vcs: str + commit_id: str + requested_revision: str | None = None + + def __init__( + self, + *, + vcs: str, + commit_id: str, + requested_revision: str | None = None, + ) -> None: + object.__setattr__(self, "vcs", vcs) + object.__setattr__(self, "commit_id", commit_id) + object.__setattr__(self, "requested_revision", requested_revision) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + # We can't validate vcs value because is not closed. + return cls( + vcs=_get_required(d, str, "vcs"), + requested_revision=_get(d, str, "requested_revision"), + commit_id=_get_required(d, str, "commit_id"), + ) + + +@dataclasses.dataclass(frozen=True, init=False) +class ArchiveInfo: + hashes: Mapping[str, str] | None = None + + def __init__( + self, + *, + hashes: Mapping[str, str] | None = None, + ) -> None: + object.__setattr__(self, "hashes", hashes) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + hashes = _get(d, Mapping, "hashes") # type: ignore[type-abstract] + if hashes is not None and not all(isinstance(h, str) for h in hashes.values()): + raise DirectUrlValidationError( + "Hash values must be strings", context="hashes" + ) + legacy_hash = _get(d, str, "hash") + if legacy_hash is not None: + if "=" not in legacy_hash: + raise DirectUrlValidationError( + "Invalid hash format (expected '=')", + context="hash", + ) + hash_algorithm, hash_value = legacy_hash.split("=", 1) + if hashes is None: + # if `hashes` are not present, we can derive it from the legacy `hash` + hashes = {hash_algorithm: hash_value} + else: + # if `hashes` are present, the legacy `hash` must match one of them + if hash_algorithm not in hashes: + raise DirectUrlValidationError( + f"Algorithm {hash_algorithm!r} used in hash field " + f"is not present in hashes field", + context="hashes", + ) + if hashes[hash_algorithm] != hash_value: + raise DirectUrlValidationError( + f"Algorithm {hash_algorithm!r} used in hash field " + f"has different value in hashes field", + context="hash", + ) + return cls(hashes=hashes) + + +@dataclasses.dataclass(frozen=True, init=False) +class DirInfo: + editable: bool | None = None + + def __init__( + self, + *, + editable: bool | None = None, + ) -> None: + object.__setattr__(self, "editable", editable) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + return cls( + editable=_get(d, bool, "editable"), + ) + + +@dataclasses.dataclass(frozen=True, init=False) +class DirectUrl: + """A class representing a direct URL.""" + + url: str + archive_info: ArchiveInfo | None = None + vcs_info: VcsInfo | None = None + dir_info: DirInfo | None = None + subdirectory: str | None = None # XXX Path or str? + + def __init__( + self, + *, + url: str, + archive_info: ArchiveInfo | None = None, + vcs_info: VcsInfo | None = None, + dir_info: DirInfo | None = None, + subdirectory: str | None = None, + ) -> None: + object.__setattr__(self, "url", url) + object.__setattr__(self, "archive_info", archive_info) + object.__setattr__(self, "vcs_info", vcs_info) + object.__setattr__(self, "dir_info", dir_info) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + direct_url = cls( + url=_get_required(d, str, "url"), + archive_info=_get_object(d, ArchiveInfo, "archive_info"), + vcs_info=_get_object(d, VcsInfo, "vcs_info"), + dir_info=_get_object(d, DirInfo, "dir_info"), + subdirectory=_get(d, str, "subdirectory"), + ) + if ( + bool(direct_url.vcs_info) + + bool(direct_url.archive_info) + + bool(direct_url.dir_info) + ) != 1: + raise DirectUrlValidationError( + "Exactly one of vcs_info, archive_info, dir_info must be present" + ) + if direct_url.dir_info is not None and not direct_url.url.startswith("file://"): + raise DirectUrlValidationError( + "URL scheme must be file:// when dir_info is present", + context="url", + ) + # XXX subdirectory must be relative, can we, should we validate that here? + return direct_url + + @classmethod + def from_dict(cls, d: Mapping[str, Any], /) -> Self: + """Create and validate a DirectUrl instance from a JSON dictionary.""" + return cls._from_dict(d) + + def to_dict( + self, + *, + generate_legacy_hash: bool = False, + strip_user_password: bool = True, + safe_user_passwords: Collection[str] = ("git",), + ) -> Mapping[str, Any]: + """Convert the DirectUrl instance to a JSON dictionary. + + :param generate_legacy_hash: If True, include a legacy `hash` field in + `archive_info` for backward compatibility with tools that don't + support the `hashes` field. + :param strip_user_password: If True, strip user:password from the URL + unless it is formed with environment variables as specified in PEP + 610, or it is a safe user:password such as `git`. + :param safe_user_passwords: A collection of user:password strings that + should not be stripped from the URL even if `strip_user_password` is + True. + """ + res = dataclasses.asdict(self, dict_factory=_json_dict_factory) + if generate_legacy_hash and self.archive_info and self.archive_info.hashes: + hash_algorithm, hash_value = next(iter(self.archive_info.hashes.items())) + res["archive_info"]["hash"] = f"{hash_algorithm}={hash_value}" + if strip_user_password: + res["url"] = _strip_url(self.url, safe_user_passwords) + return res + + def validate(self) -> None: + """Validate the DirectUrl instance against the specification. + + Raises :class:`DirectUrlValidationError` if invalid. + """ + self.from_dict(self.to_dict()) diff --git a/server/libs/packaging/errors.py b/server/libs/packaging/errors.py new file mode 100644 index 0000000..d1d47cf --- /dev/null +++ b/server/libs/packaging/errors.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import contextlib +import dataclasses +import sys +import typing + +__all__ = ["ExceptionGroup"] + + +def __dir__() -> list[str]: + return __all__ + + +if sys.version_info >= (3, 11): # pragma: no cover + from builtins import ExceptionGroup +else: # pragma: no cover + + class ExceptionGroup(Exception): + """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. + + If :external:exc:`ExceptionGroup` is already defined by Python itself, + that version is used instead. + """ + + message: str + exceptions: list[Exception] + + def __init__(self, message: str, exceptions: list[Exception]) -> None: + self.message = message + self.exceptions = exceptions + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" + + +@dataclasses.dataclass +class _ErrorCollector: + """ + Collect errors into ExceptionGroups. + + Used like this: + + collector = _ErrorCollector() + # Add a single exception + collector.error(ValueError("one")) + + # Supports nesting, including combining ExceptionGroups + with collector.collect(): + raise ValueError("two") + collector.finalize("Found some errors") + + Since making a collector and then calling finalize later is a common pattern, + a convenience method ``on_exit`` is provided. + """ + + errors: list[Exception] = dataclasses.field(default_factory=list, init=False) + + def finalize(self, msg: str) -> None: + """Raise a group exception if there are any errors.""" + if self.errors: + raise ExceptionGroup(msg, self.errors) + + @contextlib.contextmanager + def on_exit(self, msg: str) -> typing.Generator[_ErrorCollector, None, None]: + """ + Calls finalize if no uncollected errors were present. + + Uncollected errors are raised normally. + """ + yield self + self.finalize(msg) + + @contextlib.contextmanager + def collect(self, *err_cls: type[Exception]) -> typing.Generator[None, None, None]: + """ + Context manager to collect errors into the error list. + + Must be inside loops, as only one error can be collected at a time. + """ + error_classes = err_cls or (Exception,) + try: + yield + except ExceptionGroup as error: + self.errors.extend(error.exceptions) + except error_classes as error: + self.errors.append(error) + + def error( + self, + error: Exception, + ) -> None: + """Add an error to the list.""" + self.errors.append(error) diff --git a/server/libs/packaging/licenses/__init__.py b/server/libs/packaging/licenses/__init__.py index 6f7f9e6..36e46ed 100644 --- a/server/libs/packaging/licenses/__init__.py +++ b/server/libs/packaging/licenses/__init__.py @@ -34,7 +34,7 @@ from __future__ import annotations import re from typing import NewType, cast -from packaging.licenses._spdx import EXCEPTIONS, LICENSES +from ._spdx import EXCEPTIONS, LICENSES __all__ = [ "InvalidLicenseExpression", @@ -42,14 +42,25 @@ __all__ = [ "canonicalize_license_expression", ] + +# Simple __dir__ implementation since there are no public submodules +def __dir__() -> list[str]: + return __all__ + + license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$") NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str) +""" +A :class:`typing.NewType` of :class:`str`, representing a normalized +License-Expression. +""" class InvalidLicenseExpression(ValueError): """Raised when a license-expression string is invalid + >>> from packaging.licenses import canonicalize_license_expression >>> canonicalize_license_expression("invalid") Traceback (most recent call last): ... @@ -60,6 +71,34 @@ class InvalidLicenseExpression(ValueError): def canonicalize_license_expression( raw_license_expression: str, ) -> NormalizedLicenseExpression: + """ + This function takes a valid License-Expression, and returns the normalized + form of it. + + The return type is typed as :class:`NormalizedLicenseExpression`. This + allows type checkers to help require that a string has passed through this + function before use. + + :param str raw_license_expression: The License-Expression to canonicalize. + :raises InvalidLicenseExpression: If the License-Expression is invalid due to an + invalid/unknown license identifier or invalid syntax. + + .. doctest:: + + >>> from packaging.licenses import canonicalize_license_expression + >>> canonicalize_license_expression("mit") + 'MIT' + >>> canonicalize_license_expression("mit and (apache-2.0 or bsd-2-clause)") + 'MIT AND (Apache-2.0 OR BSD-2-Clause)' + >>> canonicalize_license_expression("(mit") + Traceback (most recent call last): + ... + InvalidLicenseExpression: Invalid license expression: '(mit' + >>> canonicalize_license_expression("Use-it-after-midnight") + Traceback (most recent call last): + ... + InvalidLicenseExpression: Unknown license: 'Use-it-after-midnight' + """ if not raw_license_expression: message = f"Invalid license expression: {raw_license_expression!r}" raise InvalidLicenseExpression(message) @@ -80,16 +119,21 @@ def canonicalize_license_expression( tokens = license_expression.split() - # Rather than implementing boolean logic, we create an expression that Python can - # parse. Everything that is not involved with the grammar itself is treated as - # `False` and the expression should evaluate as such. + # Rather than implementing a parenthesis/boolean logic parser, create an + # expression that Python can parse. Everything that is not involved with the + # grammar itself is replaced with the placeholder `False` and the resultant + # expression should become a valid Python expression. python_tokens = [] for token in tokens: if token not in {"or", "and", "with", "(", ")"}: python_tokens.append("False") elif token == "with": python_tokens.append("or") - elif token == "(" and python_tokens and python_tokens[-1] not in {"or", "and"}: + elif ( + token == "(" + and python_tokens + and python_tokens[-1] not in {"or", "and", "("} + ) or (token == ")" and python_tokens and python_tokens[-1] == "("): message = f"Invalid license expression: {raw_license_expression!r}" raise InvalidLicenseExpression(message) else: @@ -97,11 +141,8 @@ def canonicalize_license_expression( python_expression = " ".join(python_tokens) try: - invalid = eval(python_expression, globals(), locals()) - except Exception: - invalid = True - - if invalid is not False: + compile(python_expression, "", "eval") + except SyntaxError: message = f"Invalid license expression: {raw_license_expression!r}" raise InvalidLicenseExpression(message) from None @@ -140,6 +181,6 @@ def canonicalize_license_expression( normalized_expression = " ".join(normalized_tokens) return cast( - NormalizedLicenseExpression, + "NormalizedLicenseExpression", normalized_expression.replace("( ", "(").replace(" )", ")"), ) diff --git a/server/libs/packaging/licenses/_spdx.py b/server/libs/packaging/licenses/_spdx.py index eac2227..a277af2 100644 --- a/server/libs/packaging/licenses/_spdx.py +++ b/server/libs/packaging/licenses/_spdx.py @@ -12,7 +12,7 @@ class SPDXException(TypedDict): deprecated: bool -VERSION = '3.25.0' +VERSION = '3.27.0' LICENSES: dict[str, SPDXLicense] = { '0bsd': {'id': '0BSD', 'deprecated': False}, @@ -46,6 +46,7 @@ LICENSES: dict[str, SPDXLicense] = { 'antlr-pd': {'id': 'ANTLR-PD', 'deprecated': False}, 'antlr-pd-fallback': {'id': 'ANTLR-PD-fallback', 'deprecated': False}, 'any-osi': {'id': 'any-OSI', 'deprecated': False}, + 'any-osi-perl-modules': {'id': 'any-OSI-perl-modules', 'deprecated': False}, 'apache-1.0': {'id': 'Apache-1.0', 'deprecated': False}, 'apache-1.1': {'id': 'Apache-1.1', 'deprecated': False}, 'apache-2.0': {'id': 'Apache-2.0', 'deprecated': False}, @@ -61,6 +62,8 @@ LICENSES: dict[str, SPDXLicense] = { 'artistic-1.0-cl8': {'id': 'Artistic-1.0-cl8', 'deprecated': False}, 'artistic-1.0-perl': {'id': 'Artistic-1.0-Perl', 'deprecated': False}, 'artistic-2.0': {'id': 'Artistic-2.0', 'deprecated': False}, + 'artistic-dist': {'id': 'Artistic-dist', 'deprecated': False}, + 'aspell-ru': {'id': 'Aspell-RU', 'deprecated': False}, 'aswf-digital-assets-1.0': {'id': 'ASWF-Digital-Assets-1.0', 'deprecated': False}, 'aswf-digital-assets-1.1': {'id': 'ASWF-Digital-Assets-1.1', 'deprecated': False}, 'baekmuk': {'id': 'Baekmuk', 'deprecated': False}, @@ -75,6 +78,7 @@ LICENSES: dict[str, SPDXLicense] = { 'blessing': {'id': 'blessing', 'deprecated': False}, 'blueoak-1.0.0': {'id': 'BlueOak-1.0.0', 'deprecated': False}, 'boehm-gc': {'id': 'Boehm-GC', 'deprecated': False}, + 'boehm-gc-without-fee': {'id': 'Boehm-GC-without-fee', 'deprecated': False}, 'borceux': {'id': 'Borceux', 'deprecated': False}, 'brian-gladman-2-clause': {'id': 'Brian-Gladman-2-Clause', 'deprecated': False}, 'brian-gladman-3-clause': {'id': 'Brian-Gladman-3-Clause', 'deprecated': False}, @@ -85,6 +89,7 @@ LICENSES: dict[str, SPDXLicense] = { 'bsd-2-clause-freebsd': {'id': 'BSD-2-Clause-FreeBSD', 'deprecated': True}, 'bsd-2-clause-netbsd': {'id': 'BSD-2-Clause-NetBSD', 'deprecated': True}, 'bsd-2-clause-patent': {'id': 'BSD-2-Clause-Patent', 'deprecated': False}, + 'bsd-2-clause-pkgconf-disclaimer': {'id': 'BSD-2-Clause-pkgconf-disclaimer', 'deprecated': False}, 'bsd-2-clause-views': {'id': 'BSD-2-Clause-Views', 'deprecated': False}, 'bsd-3-clause': {'id': 'BSD-3-Clause', 'deprecated': False}, 'bsd-3-clause-acpica': {'id': 'BSD-3-Clause-acpica', 'deprecated': False}, @@ -176,6 +181,8 @@ LICENSES: dict[str, SPDXLicense] = { 'cc-by-sa-3.0-igo': {'id': 'CC-BY-SA-3.0-IGO', 'deprecated': False}, 'cc-by-sa-4.0': {'id': 'CC-BY-SA-4.0', 'deprecated': False}, 'cc-pddc': {'id': 'CC-PDDC', 'deprecated': False}, + 'cc-pdm-1.0': {'id': 'CC-PDM-1.0', 'deprecated': False}, + 'cc-sa-1.0': {'id': 'CC-SA-1.0', 'deprecated': False}, 'cc0-1.0': {'id': 'CC0-1.0', 'deprecated': False}, 'cddl-1.0': {'id': 'CDDL-1.0', 'deprecated': False}, 'cddl-1.1': {'id': 'CDDL-1.1', 'deprecated': False}, @@ -215,6 +222,7 @@ LICENSES: dict[str, SPDXLicense] = { 'cpol-1.02': {'id': 'CPOL-1.02', 'deprecated': False}, 'cronyx': {'id': 'Cronyx', 'deprecated': False}, 'crossword': {'id': 'Crossword', 'deprecated': False}, + 'cryptoswift': {'id': 'CryptoSwift', 'deprecated': False}, 'crystalstacker': {'id': 'CrystalStacker', 'deprecated': False}, 'cua-opl-1.0': {'id': 'CUA-OPL-1.0', 'deprecated': False}, 'cube': {'id': 'Cube', 'deprecated': False}, @@ -226,7 +234,9 @@ LICENSES: dict[str, SPDXLicense] = { 'dl-de-by-2.0': {'id': 'DL-DE-BY-2.0', 'deprecated': False}, 'dl-de-zero-2.0': {'id': 'DL-DE-ZERO-2.0', 'deprecated': False}, 'doc': {'id': 'DOC', 'deprecated': False}, + 'docbook-dtd': {'id': 'DocBook-DTD', 'deprecated': False}, 'docbook-schema': {'id': 'DocBook-Schema', 'deprecated': False}, + 'docbook-stylesheet': {'id': 'DocBook-Stylesheet', 'deprecated': False}, 'docbook-xml': {'id': 'DocBook-XML', 'deprecated': False}, 'dotseqn': {'id': 'Dotseqn', 'deprecated': False}, 'drl-1.0': {'id': 'DRL-1.0', 'deprecated': False}, @@ -263,12 +273,17 @@ LICENSES: dict[str, SPDXLicense] = { 'fsfap-no-warranty-disclaimer': {'id': 'FSFAP-no-warranty-disclaimer', 'deprecated': False}, 'fsful': {'id': 'FSFUL', 'deprecated': False}, 'fsfullr': {'id': 'FSFULLR', 'deprecated': False}, + 'fsfullrsd': {'id': 'FSFULLRSD', 'deprecated': False}, 'fsfullrwd': {'id': 'FSFULLRWD', 'deprecated': False}, + 'fsl-1.1-alv2': {'id': 'FSL-1.1-ALv2', 'deprecated': False}, + 'fsl-1.1-mit': {'id': 'FSL-1.1-MIT', 'deprecated': False}, 'ftl': {'id': 'FTL', 'deprecated': False}, 'furuseth': {'id': 'Furuseth', 'deprecated': False}, 'fwlw': {'id': 'fwlw', 'deprecated': False}, + 'game-programming-gems': {'id': 'Game-Programming-Gems', 'deprecated': False}, 'gcr-docs': {'id': 'GCR-docs', 'deprecated': False}, 'gd': {'id': 'GD', 'deprecated': False}, + 'generic-xts': {'id': 'generic-xts', 'deprecated': False}, 'gfdl-1.1': {'id': 'GFDL-1.1', 'deprecated': True}, 'gfdl-1.1-invariants-only': {'id': 'GFDL-1.1-invariants-only', 'deprecated': False}, 'gfdl-1.1-invariants-or-later': {'id': 'GFDL-1.1-invariants-or-later', 'deprecated': False}, @@ -320,6 +335,7 @@ LICENSES: dict[str, SPDXLicense] = { 'gtkbook': {'id': 'gtkbook', 'deprecated': False}, 'gutmann': {'id': 'Gutmann', 'deprecated': False}, 'haskellreport': {'id': 'HaskellReport', 'deprecated': False}, + 'hdf5': {'id': 'HDF5', 'deprecated': False}, 'hdparm': {'id': 'hdparm', 'deprecated': False}, 'hidapi': {'id': 'HIDAPI', 'deprecated': False}, 'hippocratic-2.1': {'id': 'Hippocratic-2.1', 'deprecated': False}, @@ -360,6 +376,7 @@ LICENSES: dict[str, SPDXLicense] = { 'imlib2': {'id': 'Imlib2', 'deprecated': False}, 'info-zip': {'id': 'Info-ZIP', 'deprecated': False}, 'inner-net-2.0': {'id': 'Inner-Net-2.0', 'deprecated': False}, + 'innosetup': {'id': 'InnoSetup', 'deprecated': False}, 'intel': {'id': 'Intel', 'deprecated': False}, 'intel-acpi': {'id': 'Intel-ACPI', 'deprecated': False}, 'interbase-1.0': {'id': 'Interbase-1.0', 'deprecated': False}, @@ -369,6 +386,7 @@ LICENSES: dict[str, SPDXLicense] = { 'isc-veillard': {'id': 'ISC-Veillard', 'deprecated': False}, 'jam': {'id': 'Jam', 'deprecated': False}, 'jasper-2.0': {'id': 'JasPer-2.0', 'deprecated': False}, + 'jove': {'id': 'jove', 'deprecated': False}, 'jpl-image': {'id': 'JPL-image', 'deprecated': False}, 'jpnic': {'id': 'JPNIC', 'deprecated': False}, 'json': {'id': 'JSON', 'deprecated': False}, @@ -394,6 +412,7 @@ LICENSES: dict[str, SPDXLicense] = { 'lgpl-3.0-or-later': {'id': 'LGPL-3.0-or-later', 'deprecated': False}, 'lgpllr': {'id': 'LGPLLR', 'deprecated': False}, 'libpng': {'id': 'Libpng', 'deprecated': False}, + 'libpng-1.6.35': {'id': 'libpng-1.6.35', 'deprecated': False}, 'libpng-2.0': {'id': 'libpng-2.0', 'deprecated': False}, 'libselinux-1.0': {'id': 'libselinux-1.0', 'deprecated': False}, 'libtiff': {'id': 'libtiff', 'deprecated': False}, @@ -424,14 +443,17 @@ LICENSES: dict[str, SPDXLicense] = { 'magaz': {'id': 'magaz', 'deprecated': False}, 'mailprio': {'id': 'mailprio', 'deprecated': False}, 'makeindex': {'id': 'MakeIndex', 'deprecated': False}, + 'man2html': {'id': 'man2html', 'deprecated': False}, 'martin-birgmeier': {'id': 'Martin-Birgmeier', 'deprecated': False}, 'mcphee-slideshow': {'id': 'McPhee-slideshow', 'deprecated': False}, 'metamail': {'id': 'metamail', 'deprecated': False}, 'minpack': {'id': 'Minpack', 'deprecated': False}, + 'mips': {'id': 'MIPS', 'deprecated': False}, 'miros': {'id': 'MirOS', 'deprecated': False}, 'mit': {'id': 'MIT', 'deprecated': False}, 'mit-0': {'id': 'MIT-0', 'deprecated': False}, 'mit-advertising': {'id': 'MIT-advertising', 'deprecated': False}, + 'mit-click': {'id': 'MIT-Click', 'deprecated': False}, 'mit-cmu': {'id': 'MIT-CMU', 'deprecated': False}, 'mit-enna': {'id': 'MIT-enna', 'deprecated': False}, 'mit-feh': {'id': 'MIT-feh', 'deprecated': False}, @@ -472,6 +494,7 @@ LICENSES: dict[str, SPDXLicense] = { 'netcdf': {'id': 'NetCDF', 'deprecated': False}, 'newsletr': {'id': 'Newsletr', 'deprecated': False}, 'ngpl': {'id': 'NGPL', 'deprecated': False}, + 'ngrep': {'id': 'ngrep', 'deprecated': False}, 'nicta-1.0': {'id': 'NICTA-1.0', 'deprecated': False}, 'nist-pd': {'id': 'NIST-PD', 'deprecated': False}, 'nist-pd-fallback': {'id': 'NIST-PD-fallback', 'deprecated': False}, @@ -486,6 +509,7 @@ LICENSES: dict[str, SPDXLicense] = { 'npl-1.1': {'id': 'NPL-1.1', 'deprecated': False}, 'nposl-3.0': {'id': 'NPOSL-3.0', 'deprecated': False}, 'nrl': {'id': 'NRL', 'deprecated': False}, + 'ntia-pd': {'id': 'NTIA-PD', 'deprecated': False}, 'ntp': {'id': 'NTP', 'deprecated': False}, 'ntp-0': {'id': 'NTP-0', 'deprecated': False}, 'nunit': {'id': 'Nunit', 'deprecated': True}, @@ -580,6 +604,7 @@ LICENSES: dict[str, SPDXLicense] = { 'schemereport': {'id': 'SchemeReport', 'deprecated': False}, 'sendmail': {'id': 'Sendmail', 'deprecated': False}, 'sendmail-8.23': {'id': 'Sendmail-8.23', 'deprecated': False}, + 'sendmail-open-source-1.1': {'id': 'Sendmail-Open-Source-1.1', 'deprecated': False}, 'sgi-b-1.0': {'id': 'SGI-B-1.0', 'deprecated': False}, 'sgi-b-1.1': {'id': 'SGI-B-1.1', 'deprecated': False}, 'sgi-b-2.0': {'id': 'SGI-B-2.0', 'deprecated': False}, @@ -592,10 +617,12 @@ LICENSES: dict[str, SPDXLicense] = { 'sissl-1.2': {'id': 'SISSL-1.2', 'deprecated': False}, 'sl': {'id': 'SL', 'deprecated': False}, 'sleepycat': {'id': 'Sleepycat', 'deprecated': False}, + 'smail-gpl': {'id': 'SMAIL-GPL', 'deprecated': False}, 'smlnj': {'id': 'SMLNJ', 'deprecated': False}, 'smppl': {'id': 'SMPPL', 'deprecated': False}, 'snia': {'id': 'SNIA', 'deprecated': False}, 'snprintf': {'id': 'snprintf', 'deprecated': False}, + 'sofa': {'id': 'SOFA', 'deprecated': False}, 'softsurfer': {'id': 'softSurfer', 'deprecated': False}, 'soundex': {'id': 'Soundex', 'deprecated': False}, 'spencer-86': {'id': 'Spencer-86', 'deprecated': False}, @@ -609,6 +636,7 @@ LICENSES: dict[str, SPDXLicense] = { 'sspl-1.0': {'id': 'SSPL-1.0', 'deprecated': False}, 'standardml-nj': {'id': 'StandardML-NJ', 'deprecated': True}, 'sugarcrm-1.1.3': {'id': 'SugarCRM-1.1.3', 'deprecated': False}, + 'sul-1.0': {'id': 'SUL-1.0', 'deprecated': False}, 'sun-ppp': {'id': 'Sun-PPP', 'deprecated': False}, 'sun-ppp-2000': {'id': 'Sun-PPP-2000', 'deprecated': False}, 'sunpro': {'id': 'SunPro', 'deprecated': False}, @@ -620,12 +648,14 @@ LICENSES: dict[str, SPDXLicense] = { 'tcp-wrappers': {'id': 'TCP-wrappers', 'deprecated': False}, 'termreadkey': {'id': 'TermReadKey', 'deprecated': False}, 'tgppl-1.0': {'id': 'TGPPL-1.0', 'deprecated': False}, + 'thirdeye': {'id': 'ThirdEye', 'deprecated': False}, 'threeparttable': {'id': 'threeparttable', 'deprecated': False}, 'tmate': {'id': 'TMate', 'deprecated': False}, 'torque-1.1': {'id': 'TORQUE-1.1', 'deprecated': False}, 'tosl': {'id': 'TOSL', 'deprecated': False}, 'tpdl': {'id': 'TPDL', 'deprecated': False}, 'tpl-1.0': {'id': 'TPL-1.0', 'deprecated': False}, + 'trustedqsl': {'id': 'TrustedQSL', 'deprecated': False}, 'ttwl': {'id': 'TTWL', 'deprecated': False}, 'ttyp0': {'id': 'TTYP0', 'deprecated': False}, 'tu-berlin-1.0': {'id': 'TU-Berlin-1.0', 'deprecated': False}, @@ -641,6 +671,8 @@ LICENSES: dict[str, SPDXLicense] = { 'unicode-tou': {'id': 'Unicode-TOU', 'deprecated': False}, 'unixcrypt': {'id': 'UnixCrypt', 'deprecated': False}, 'unlicense': {'id': 'Unlicense', 'deprecated': False}, + 'unlicense-libtelnet': {'id': 'Unlicense-libtelnet', 'deprecated': False}, + 'unlicense-libwhirlpool': {'id': 'Unlicense-libwhirlpool', 'deprecated': False}, 'upl-1.0': {'id': 'UPL-1.0', 'deprecated': False}, 'urt-rle': {'id': 'URT-RLE', 'deprecated': False}, 'vim': {'id': 'Vim', 'deprecated': False}, @@ -654,6 +686,7 @@ LICENSES: dict[str, SPDXLicense] = { 'widget-workshop': {'id': 'Widget-Workshop', 'deprecated': False}, 'wsuipa': {'id': 'Wsuipa', 'deprecated': False}, 'wtfpl': {'id': 'WTFPL', 'deprecated': False}, + 'wwl': {'id': 'wwl', 'deprecated': False}, 'wxwindows': {'id': 'wxWindows', 'deprecated': True}, 'x11': {'id': 'X11', 'deprecated': False}, 'x11-distribute-modifications-variant': {'id': 'X11-distribute-modifications-variant', 'deprecated': False}, @@ -695,9 +728,11 @@ EXCEPTIONS: dict[str, SPDXException] = { 'bison-exception-1.24': {'id': 'Bison-exception-1.24', 'deprecated': False}, 'bison-exception-2.2': {'id': 'Bison-exception-2.2', 'deprecated': False}, 'bootloader-exception': {'id': 'Bootloader-exception', 'deprecated': False}, + 'cgal-linking-exception': {'id': 'CGAL-linking-exception', 'deprecated': False}, 'classpath-exception-2.0': {'id': 'Classpath-exception-2.0', 'deprecated': False}, 'clisp-exception-2.0': {'id': 'CLISP-exception-2.0', 'deprecated': False}, 'cryptsetup-openssl-exception': {'id': 'cryptsetup-OpenSSL-exception', 'deprecated': False}, + 'digia-qt-lgpl-exception-1.1': {'id': 'Digia-Qt-LGPL-exception-1.1', 'deprecated': False}, 'digirule-foss-exception': {'id': 'DigiRule-FOSS-exception', 'deprecated': False}, 'ecos-exception-2.0': {'id': 'eCos-exception-2.0', 'deprecated': False}, 'erlang-otp-linking-exception': {'id': 'erlang-otp-linking-exception', 'deprecated': False}, @@ -714,13 +749,16 @@ EXCEPTIONS: dict[str, SPDXException] = { 'gnome-examples-exception': {'id': 'GNOME-examples-exception', 'deprecated': False}, 'gnu-compiler-exception': {'id': 'GNU-compiler-exception', 'deprecated': False}, 'gnu-javamail-exception': {'id': 'gnu-javamail-exception', 'deprecated': False}, + 'gpl-3.0-389-ds-base-exception': {'id': 'GPL-3.0-389-ds-base-exception', 'deprecated': False}, 'gpl-3.0-interface-exception': {'id': 'GPL-3.0-interface-exception', 'deprecated': False}, 'gpl-3.0-linking-exception': {'id': 'GPL-3.0-linking-exception', 'deprecated': False}, 'gpl-3.0-linking-source-exception': {'id': 'GPL-3.0-linking-source-exception', 'deprecated': False}, 'gpl-cc-1.0': {'id': 'GPL-CC-1.0', 'deprecated': False}, 'gstreamer-exception-2005': {'id': 'GStreamer-exception-2005', 'deprecated': False}, 'gstreamer-exception-2008': {'id': 'GStreamer-exception-2008', 'deprecated': False}, + 'harbour-exception': {'id': 'harbour-exception', 'deprecated': False}, 'i2p-gpl-java-exception': {'id': 'i2p-gpl-java-exception', 'deprecated': False}, + 'independent-modules-exception': {'id': 'Independent-modules-exception', 'deprecated': False}, 'kicad-libraries-exception': {'id': 'KiCad-libraries-exception', 'deprecated': False}, 'lgpl-3.0-linking-exception': {'id': 'LGPL-3.0-linking-exception', 'deprecated': False}, 'libpri-openh323-exception': {'id': 'libpri-OpenH323-exception', 'deprecated': False}, @@ -730,12 +768,14 @@ EXCEPTIONS: dict[str, SPDXException] = { 'llvm-exception': {'id': 'LLVM-exception', 'deprecated': False}, 'lzma-exception': {'id': 'LZMA-exception', 'deprecated': False}, 'mif-exception': {'id': 'mif-exception', 'deprecated': False}, + 'mxml-exception': {'id': 'mxml-exception', 'deprecated': False}, 'nokia-qt-exception-1.1': {'id': 'Nokia-Qt-exception-1.1', 'deprecated': True}, 'ocaml-lgpl-linking-exception': {'id': 'OCaml-LGPL-linking-exception', 'deprecated': False}, 'occt-exception-1.0': {'id': 'OCCT-exception-1.0', 'deprecated': False}, 'openjdk-assembly-exception-1.0': {'id': 'OpenJDK-assembly-exception-1.0', 'deprecated': False}, 'openvpn-openssl-exception': {'id': 'openvpn-openssl-exception', 'deprecated': False}, 'pcre2-exception': {'id': 'PCRE2-exception', 'deprecated': False}, + 'polyparse-exception': {'id': 'polyparse-exception', 'deprecated': False}, 'ps-or-pdf-font-exception-20170817': {'id': 'PS-or-PDF-font-exception-20170817', 'deprecated': False}, 'qpl-1.0-inria-2004-exception': {'id': 'QPL-1.0-INRIA-2004-exception', 'deprecated': False}, 'qt-gpl-exception-1.0': {'id': 'Qt-GPL-exception-1.0', 'deprecated': False}, diff --git a/server/libs/packaging/markers.py b/server/libs/packaging/markers.py index e7cea57..564451f 100644 --- a/server/libs/packaging/markers.py +++ b/server/libs/packaging/markers.py @@ -8,7 +8,7 @@ import operator import os import platform import sys -from typing import AbstractSet, Any, Callable, Literal, TypedDict, Union, cast +from typing import AbstractSet, Callable, Literal, Mapping, TypedDict, Union, cast from ._parser import MarkerAtom, MarkerList, Op, Value, Variable from ._parser import parse_marker as _parse_marker @@ -17,6 +17,7 @@ from .specifiers import InvalidSpecifier, Specifier from .utils import canonicalize_name __all__ = [ + "Environment", "EvaluateContext", "InvalidMarker", "Marker", @@ -25,31 +26,58 @@ __all__ = [ "default_environment", ] + +def __dir__() -> list[str]: + return __all__ + + Operator = Callable[[str, Union[str, AbstractSet[str]]], bool] EvaluateContext = Literal["metadata", "lock_file", "requirement"] +"""A ``typing.Literal`` enumerating valid marker evaluation contexts. + +Valid values for the ``context`` passed to :meth:`Marker.evaluate` are: + +* ``"metadata"`` (for core metadata; default) +* ``"lock_file"`` (for lock files) +* ``"requirement"`` (i.e. all other situations) +""" + MARKERS_ALLOWING_SET = {"extras", "dependency_groups"} +MARKERS_REQUIRING_VERSION = { + "implementation_version", + "platform_release", + "python_full_version", + "python_version", +} class InvalidMarker(ValueError): - """ - An invalid marker was found, users should refer to PEP 508. + """Raised when attempting to create a :class:`Marker` from invalid input. + + This error indicates that the given marker string does not conform to the + :ref:`specification of dependency specifiers `. """ class UndefinedComparison(ValueError): - """ - An invalid operation was attempted on a value that doesn't support it. + """Raised when evaluating an unsupported marker comparison. + + This can happen when marker values are compared as versions but do not + conform to the :ref:`specification of version specifiers + `. """ class UndefinedEnvironmentName(ValueError): - """ - A name was attempted to be used that does not exist inside of the - environment. - """ + """Raised when evaluating a marker that references a missing environment key.""" class Environment(TypedDict): + """ + A dictionary that represents a Python environment as captured by + :func:`default_environment`. All fields are required. + """ + implementation_name: str """The implementation's identifier, e.g. ``'cpython'``.""" @@ -121,20 +149,28 @@ class Environment(TypedDict): """ -def _normalize_extra_values(results: Any) -> Any: +def _normalize_extras( + result: MarkerList | MarkerAtom | str, +) -> MarkerList | MarkerAtom | str: + if not isinstance(result, tuple): + return result + + lhs, op, rhs = result + if isinstance(lhs, Variable) and lhs.value == "extra": + normalized_extra = canonicalize_name(rhs.value) + rhs = Value(normalized_extra) + elif isinstance(rhs, Variable) and rhs.value == "extra": + normalized_extra = canonicalize_name(lhs.value) + lhs = Value(normalized_extra) + return lhs, op, rhs + + +def _normalize_extra_values(results: MarkerList) -> MarkerList: """ Normalize extra values. """ - if isinstance(results[0], tuple): - lhs, op, rhs = results[0] - if isinstance(lhs, Variable) and lhs.value == "extra": - normalized_extra = canonicalize_name(rhs.value) - rhs = Value(normalized_extra) - elif isinstance(rhs, Variable) and rhs.value == "extra": - normalized_extra = canonicalize_name(lhs.value) - lhs = Value(normalized_extra) - results[0] = lhs, op, rhs - return results + + return [_normalize_extras(r) for r in results] def _format_marker( @@ -168,25 +204,26 @@ def _format_marker( _operators: dict[str, Operator] = { "in": lambda lhs, rhs: lhs in rhs, "not in": lambda lhs, rhs: lhs not in rhs, - "<": operator.lt, - "<=": operator.le, + "<": lambda _lhs, _rhs: False, + "<=": operator.eq, "==": operator.eq, "!=": operator.ne, - ">=": operator.ge, - ">": operator.gt, + ">=": operator.eq, + ">": lambda _lhs, _rhs: False, } -def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str]) -> bool: - if isinstance(rhs, str): +def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str) -> bool: + op_str = op.serialize() + if key in MARKERS_REQUIRING_VERSION: try: - spec = Specifier("".join([op.serialize(), rhs])) + spec = Specifier(f"{op_str}{rhs}") except InvalidSpecifier: pass else: return spec.contains(lhs, prereleases=True) - oper: Operator | None = _operators.get(op.serialize()) + oper: Operator | None = _operators.get(op_str) if oper is None: raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.") @@ -196,13 +233,14 @@ def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str]) -> bool: def _normalize( lhs: str, rhs: str | AbstractSet[str], key: str ) -> tuple[str, str | AbstractSet[str]]: - # PEP 685 – Comparison of extra names for optional distribution dependencies + # PEP 685 - Comparison of extra names for optional distribution dependencies # https://peps.python.org/pep-0685/ # > When comparing extra names, tools MUST normalize the names being # > compared using the semantics outlined in PEP 503 for names if key == "extra": assert isinstance(rhs, str), "extra value must be a string" - return (canonicalize_name(lhs), canonicalize_name(rhs)) + # Both sides are normalized at this point already + return (lhs, rhs) if key in MARKERS_ALLOWING_SET: if isinstance(rhs, str): # pragma: no cover return (canonicalize_name(lhs), canonicalize_name(rhs)) @@ -219,8 +257,6 @@ def _evaluate_markers( groups: list[list[bool]] = [[]] for marker in markers: - assert isinstance(marker, (list, tuple, str)) - if isinstance(marker, list): groups[-1].append(_evaluate_markers(marker, environment)) elif isinstance(marker, tuple): @@ -234,18 +270,21 @@ def _evaluate_markers( lhs_value = lhs.value environment_key = rhs.value rhs_value = environment[environment_key] + assert isinstance(lhs_value, str), "lhs must be a string" lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key) - groups[-1].append(_eval_op(lhs_value, op, rhs_value)) - else: - assert marker in ["and", "or"] - if marker == "or": - groups.append([]) + groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key)) + elif marker == "or": + groups.append([]) + elif marker == "and": + pass + else: # pragma: nocover + raise TypeError(f"Unexpected marker {marker!r}") return any(all(item) for item in groups) -def format_full_version(info: sys._version_info) -> str: +def _format_full_version(info: sys._version_info) -> str: version = f"{info.major}.{info.minor}.{info.micro}" kind = info.releaselevel if kind != "final": @@ -254,7 +293,11 @@ def format_full_version(info: sys._version_info) -> str: def default_environment() -> Environment: - iver = format_full_version(sys.implementation.version) + """Return the default marker environment for the current Python process. + + This is the base environment used by :meth:`Marker.evaluate`. + """ + iver = _format_full_version(sys.implementation.version) implementation_name = sys.implementation.name return { "implementation_name": implementation_name, @@ -272,10 +315,36 @@ def default_environment() -> Environment: class Marker: + """Represents a parsed dependency marker expression. + + Marker expressions are parsed according to the + :ref:`specification of dependency specifiers `. + + :param marker: The string representation of a marker expression. + :raises InvalidMarker: If ``marker`` cannot be parsed. + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging releases. + + .. versionchanged:: 26.2 + + Added a stable pickle format. Pickles created with packaging 26.2+ can + be unpickled with future releases. Backward compatibility with pickles + from packaging < 26.2 is supported but may be removed in a future + release. + """ + + __slots__ = ("_markers",) + def __init__(self, marker: str) -> None: # Note: We create a Marker object without calling this constructor in # packaging.requirements.Requirement. If any additional logic is # added here, make sure to mirror/adapt Requirement. + + # If this fails and throws an error, the repr still expects _markers to + # be defined. + self._markers: MarkerList = [] + try: self._markers = _normalize_extra_values(_parse_marker(marker)) # The attribute `_markers` can be described in terms of a recursive type: @@ -297,36 +366,94 @@ class Marker: except ParserSyntaxError as e: raise InvalidMarker(str(e)) from e + @classmethod + def _from_markers(cls, markers: MarkerList) -> Marker: + """Create a Marker instance from a pre-parsed marker tree. + + This avoids re-parsing serialised marker strings when combining markers. + """ + new = cls.__new__(cls) + new._markers = markers + return new + def __str__(self) -> str: return _format_marker(self._markers) def __repr__(self) -> str: - return f"" + return f"<{self.__class__.__name__}({str(self)!r})>" def __hash__(self) -> int: - return hash((self.__class__.__name__, str(self))) + return hash(str(self)) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, Marker): return NotImplemented return str(self) == str(other) + def __getstate__(self) -> str: + # Return the marker expression string for compactness and stability. + # Internal Node objects are excluded; the string is re-parsed on load. + return str(self) + + def __setstate__(self, state: object) -> None: + if isinstance(state, str): + # New format (26.2+): just the marker expression string. + try: + self._markers = _normalize_extra_values(_parse_marker(state)) + except ParserSyntaxError as exc: + raise TypeError(f"Cannot restore Marker from {state!r}") from exc + return + if isinstance(state, dict) and "_markers" in state: + # Old format (packaging <= 26.1, no __slots__): plain __dict__. + markers = state["_markers"] + if isinstance(markers, list): + self._markers = markers + return + if isinstance(state, tuple) and len(state) == 2: + # Old format (packaging <= 26.1, __slots__): (None, {slot: value}). + _, slot_dict = state + if isinstance(slot_dict, dict) and "_markers" in slot_dict: + markers = slot_dict["_markers"] + if isinstance(markers, list): + self._markers = markers + return + raise TypeError(f"Cannot restore Marker from {state!r}") + + def __and__(self, other: Marker) -> Marker: + if not isinstance(other, Marker): + return NotImplemented + return self._from_markers([self._markers, "and", other._markers]) + + def __or__(self, other: Marker) -> Marker: + if not isinstance(other, Marker): + return NotImplemented + return self._from_markers([self._markers, "or", other._markers]) + def evaluate( self, - environment: dict[str, str] | None = None, + environment: Mapping[str, str | AbstractSet[str]] | None = None, context: EvaluateContext = "metadata", ) -> bool: """Evaluate a marker. - Return the boolean from evaluating the given marker against the - environment. environment is an optional argument to override all or - part of the determined environment. The *context* parameter specifies what - context the markers are being evaluated for, which influences what markers - are considered valid. Acceptable values are "metadata" (for core metadata; - default), "lock_file", and "requirement" (i.e. all other situations). + Return the boolean from evaluating this marker against the environment. + The environment is determined from the current Python process unless + passed in explicitly. + + :param environment: Mapping containing keys and values to override the + detected environment. + :param EvaluateContext context: The context in which the marker is + evaluated, which influences what marker names are considered valid. + Accepted values are ``"metadata"`` (for core metadata; default), + ``"lock_file"``, and ``"requirement"`` (i.e. all other situations). + :raises UndefinedComparison: If the marker uses a comparison on values + that are not valid versions per the :ref:`specification of version + specifiers `. + :raises UndefinedEnvironmentName: If the marker references a value that + is missing from the evaluation environment. + :returns: ``True`` if the marker matches, otherwise ``False``. - The environment is determined from the current Python process. """ current_environment = cast( "dict[str, str | AbstractSet[str]]", default_environment() @@ -337,12 +464,15 @@ class Marker: ) elif context == "metadata": current_environment["extra"] = "" + if environment is not None: current_environment.update(environment) - # The API used to allow setting extra to None. We need to handle this - # case for backwards compatibility. - if "extra" in current_environment and current_environment["extra"] is None: - current_environment["extra"] = "" + if "extra" in current_environment: + # The API used to allow setting extra to None. We need to handle + # this case for backwards compatibility. Also skip running + # normalize name if extra is empty. + extra = cast("str | None", current_environment["extra"]) + current_environment["extra"] = canonicalize_name(extra) if extra else "" return _evaluate_markers( self._markers, _repair_python_full_version(current_environment) @@ -356,7 +486,7 @@ def _repair_python_full_version( Work around platform.python_version() returning something that is not PEP 440 compliant for non-tagged Python builds. """ - python_full_version = cast(str, env["python_full_version"]) + python_full_version = cast("str", env["python_full_version"]) if python_full_version.endswith("+"): env["python_full_version"] = f"{python_full_version}local" return env diff --git a/server/libs/packaging/metadata.py b/server/libs/packaging/metadata.py index 3bd8602..dccb627 100644 --- a/server/libs/packaging/metadata.py +++ b/server/libs/packaging/metadata.py @@ -1,12 +1,11 @@ from __future__ import annotations -import email.feedparser import email.header import email.message import email.parser import email.policy +import keyword import pathlib -import sys import typing from typing import ( Any, @@ -19,31 +18,27 @@ from typing import ( from . import licenses, requirements, specifiers, utils from . import version as version_module -from .licenses import NormalizedLicenseExpression +from .errors import ExceptionGroup, _ErrorCollector + +if typing.TYPE_CHECKING: + from .licenses import NormalizedLicenseExpression T = typing.TypeVar("T") -if sys.version_info >= (3, 11): # pragma: no cover - ExceptionGroup = ExceptionGroup -else: # pragma: no cover +__all__ = [ + "ExceptionGroup", # Keep this for a bit (makes mypy happy w/ 26.0 compat) + "InvalidMetadata", + "Metadata", + "RFC822Message", + "RFC822Policy", + "RawMetadata", + "parse_email", +] - class ExceptionGroup(Exception): - """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. - If :external:exc:`ExceptionGroup` is already defined by Python itself, - that version is used instead. - """ - - message: str - exceptions: list[Exception] - - def __init__(self, message: str, exceptions: list[Exception]) -> None: - self.message = message - self.exceptions = exceptions - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" +def __dir__() -> list[str]: + return __all__ class InvalidMetadata(ValueError): @@ -73,8 +68,8 @@ class RawMetadata(TypedDict, total=False): Core metadata fields that can be specified multiple times are stored as a list or dict depending on which is appropriate for the field. Any fields - which hold multiple values in a single field are stored as a list. - + which hold multiple values in a single field are stored as a list. All fields + are considered optional. """ # Metadata 1.0 - PEP 241 @@ -126,13 +121,19 @@ class RawMetadata(TypedDict, total=False): # Metadata 2.3 - PEP 685 # No new fields were added in PEP 685, just some edge case were - # tightened up to provide better interoptability. + # tightened up to provide better interoperability. # Metadata 2.4 - PEP 639 license_expression: str license_files: list[str] + # Metadata 2.5 - PEP 794 + import_names: list[str] + import_namespaces: list[str] + +# 'keywords' is special as it's a string in the core metadata spec, but we +# represent it as a list. _STRING_FIELDS = { "author", "author_email", @@ -165,6 +166,8 @@ _LIST_FIELDS = { "requires_dist", "requires_external", "supported_platforms", + "import_names", + "import_namespaces", } _DICT_FIELDS = { @@ -193,24 +196,23 @@ def _parse_project_urls(data: list[str]) -> dict[str, str]: # be the missing value, then they'd have multiple '' values that # overwrite each other in a accumulating dict. # - # The other potentional issue is that it's possible to have the + # The other potential issue is that it's possible to have the # same label multiple times in the metadata, with no solid "right" # answer with what to do in that case. As such, we'll do the only - # thing we can, which is treat the field as unparseable and add it + # thing we can, which is treat the field as unparsable and add it # to our list of unparsed fields. - parts = [p.strip() for p in pair.split(",", 1)] - parts.extend([""] * (max(0, 2 - len(parts)))) # Ensure 2 items - + # # TODO: The spec doesn't say anything about if the keys should be # considered case sensitive or not... logically they should # be case-preserving and case-insensitive, but doing that # would open up more cases where we might have duplicate # entries. - label, url = parts + label, _, url = (s.strip() for s in pair.partition(",")) + if label in urls: # The label already exists in our set of urls, so this field - # is unparseable, and we can just add the whole thing to our - # unparseable data and stop processing it. + # is unparsable, and we can just add the whole thing to our + # unparsable data and stop processing it. raise KeyError("duplicate labels in project urls") urls[label] = url @@ -257,6 +259,8 @@ _EMAIL_TO_RAW_MAPPING = { "download-url": "download_url", "dynamic": "dynamic", "home-page": "home_page", + "import-name": "import_names", + "import-namespace": "import_namespaces", "keywords": "keywords", "license": "license", "license-expression": "license_expression", @@ -283,6 +287,45 @@ _EMAIL_TO_RAW_MAPPING = { _RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()} +# This class is for writing RFC822 messages +class RFC822Policy(email.policy.EmailPolicy): + """ + This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse`` + implementation that handles multi-line values, and some nice defaults. + """ + + utf8 = True + mangle_from_ = False + max_line_length = 0 + + def header_store_parse(self, name: str, value: str) -> tuple[str, str]: + size = len(name) + 2 + value = value.replace("\n", "\n" + " " * size) + return (name, value) + + +# This class is for writing RFC822 messages +class RFC822Message(email.message.EmailMessage): + """ + This is :class:`email.message.EmailMessage` with two small changes: it defaults to + our `RFC822Policy`, and it correctly writes unicode when being called + with `bytes()`. + """ + + def __init__(self) -> None: + super().__init__(policy=RFC822Policy()) + + def as_bytes( + self, unixfrom: bool = False, policy: email.policy.Policy | None = None + ) -> bytes: + """ + Return the bytes representation of the message. + + This handles unicode encoding. + """ + return self.as_string(unixfrom, policy=policy).encode("utf-8") + + def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: """Parse a distribution's metadata stored as email headers (e.g. from ``METADATA``). @@ -310,10 +353,10 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: # We have to wrap parsed.keys() in a set, because in the case of multiple # values for a key (a list), the key will appear multiple times in the # list of keys, but we're avoiding that by using get_all(). - for name in frozenset(parsed.keys()): + for name_with_case in frozenset(parsed.keys()): # Header names in RFC are case insensitive, so we'll normalize to all # lower case to make comparisons easier. - name = name.lower() + name = name_with_case.lower() # We use get_all() here, even for fields that aren't multiple use, # because otherwise someone could have e.g. two Name fields, and we @@ -349,16 +392,16 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: # can be independently encoded, so we'll need to check each # of them. chunks: list[tuple[bytes, str | None]] = [] - for bin, encoding in email.header.decode_header(h): + for binary, _encoding in email.header.decode_header(h): try: - bin.decode("utf8", "strict") + binary.decode("utf8", "strict") except UnicodeDecodeError: # Enable mojibake. encoding = "latin1" valid_encoding = False else: encoding = "utf8" - chunks.append((bin, encoding)) + chunks.append((binary, encoding)) # Turn our chunks back into a Header object, then let that # Header object do the right thing to turn them into a @@ -397,6 +440,11 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: # of unparsed stuff. if raw_name in _STRING_FIELDS and len(value) == 1: raw[raw_name] = value[0] + # If this is import_names, we need to special case the empty field + # case, which converts to an empty list instead of None. We can't let + # the empty case slip through, as it will fail validation. + elif raw_name == "import_names" and value == [""]: + raw[raw_name] = [] # If this is one of our list of string fields, then we can just assign # the value, since email *only* has strings, and our get_all() call # above ensures that this is a list. @@ -424,7 +472,7 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: except KeyError: unparsed[name] = value # Nothing that we've done has managed to parse this, so it'll just - # throw it in our unparseable data and move on. + # throw it in our unparsable data and move on. else: unparsed[name] = value @@ -441,9 +489,9 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: else: if payload: # Check to see if we've already got a description, if so then both - # it, and this body move to unparseable. + # it, and this body move to unparsable. if "description" in raw: - description_header = cast(str, raw.pop("description")) + description_header = cast("str", raw.pop("description")) unparsed.setdefault("description", []).extend( [description_header, payload] ) @@ -456,15 +504,15 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]: # literal key names, but we're computing our key names on purpose, but the # way this function is implemented, our `TypedDict` can only have valid key # names. - return cast(RawMetadata, raw), unparsed + return cast("RawMetadata", raw), unparsed _NOT_FOUND = object() # Keep the two values in sync. -_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"] -_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"] +_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"] +_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"] _REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"]) @@ -519,7 +567,7 @@ class _Validator(Generic[T]): except KeyError: pass - return cast(T, value) + return cast("T", value) def _invalid_metadata( self, msg: str, cause: Exception | None = None @@ -534,7 +582,7 @@ class _Validator(Generic[T]): # Implicitly makes Metadata-Version required. if value not in _VALID_METADATA_VERSIONS: raise self._invalid_metadata(f"{value!r} is not a valid metadata version") - return cast(_MetadataVersion, value) + return cast("_MetadataVersion", value) def _process_name(self, value: str) -> str: if not value: @@ -585,7 +633,7 @@ class _Validator(Generic[T]): charset = parameters.get("charset", "UTF-8") if charset != "UTF-8": raise self._invalid_metadata( - f"{{field}} can only specify the UTF-8 charset, not {list(charset)}" + f"{{field}} can only specify the UTF-8 charset, not {charset!r}" ) markdown_variants = {"GFM", "CommonMark"} @@ -647,9 +695,7 @@ class _Validator(Generic[T]): else: return reqs - def _process_license_expression( - self, value: str - ) -> NormalizedLicenseExpression | None: + def _process_license_expression(self, value: str) -> NormalizedLicenseExpression: try: return licenses.canonicalize_license_expression(value) except ValueError as exc: @@ -683,6 +729,30 @@ class _Validator(Generic[T]): paths.append(path) return paths + def _process_import_names(self, value: list[str]) -> list[str]: + for import_name in value: + name, semicolon, private = import_name.partition(";") + name = name.rstrip() + for identifier in name.split("."): + if not identifier.isidentifier(): + raise self._invalid_metadata( + f"{name!r} is invalid for {{field}}; " + f"{identifier!r} is not a valid identifier" + ) + elif keyword.iskeyword(identifier): + raise self._invalid_metadata( + f"{name!r} is invalid for {{field}}; " + f"{identifier!r} is a keyword" + ) + if semicolon and private.lstrip() != "private": + raise self._invalid_metadata( + f"{import_name!r} is invalid for {{field}}; " + "the only valid option is 'private'" + ) + return value + + _process_import_namespaces = _process_import_names + class Metadata: """Representation of distribution metadata. @@ -706,13 +776,11 @@ class Metadata: ins._raw = data.copy() # Mutations occur due to caching enriched values. if validate: - exceptions: list[Exception] = [] - try: + collector = _ErrorCollector() + metadata_version = None + with collector.collect(InvalidMetadata): metadata_version = ins.metadata_version metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version) - except InvalidMetadata as metadata_version_exc: - exceptions.append(metadata_version_exc) - metadata_version = None # Make sure to check for the fields that are present, the required # fields (so their absence can be reported). @@ -729,7 +797,7 @@ class Metadata: field_metadata_version = cls.__dict__[key].added except KeyError: exc = InvalidMetadata(key, f"unrecognized field: {key!r}") - exceptions.append(exc) + collector.error(exc) continue field_age = _VALID_METADATA_VERSIONS.index( field_metadata_version @@ -741,14 +809,13 @@ class Metadata: f"{field} introduced in metadata version " f"{field_metadata_version}, not {metadata_version}", ) - exceptions.append(exc) + collector.error(exc) continue getattr(ins, key) except InvalidMetadata as exc: - exceptions.append(exc) + collector.error(exc) - if exceptions: - raise ExceptionGroup("invalid metadata", exceptions) + collector.finalize("invalid metadata") return ins @@ -762,16 +829,13 @@ class Metadata: raw, unparsed = parse_email(data) if validate: - exceptions: list[Exception] = [] - for unparsed_key in unparsed: - if unparsed_key in _EMAIL_TO_RAW_MAPPING: - message = f"{unparsed_key!r} has invalid data" - else: - message = f"unrecognized field: {unparsed_key!r}" - exceptions.append(InvalidMetadata(unparsed_key, message)) - - if exceptions: - raise ExceptionGroup("unparsed", exceptions) + with _ErrorCollector().on_exit("unparsed") as collector: + for unparsed_key in unparsed: + if unparsed_key in _EMAIL_TO_RAW_MAPPING: + message = f"{unparsed_key!r} has invalid data" + else: + message = f"unrecognized field: {unparsed_key!r}" + collector.error(InvalidMetadata(unparsed_key, message)) try: return cls.from_raw(raw, validate=validate) @@ -854,9 +918,47 @@ class Metadata: """:external:ref:`core-metadata-provides-dist`""" obsoletes_dist: _Validator[list[str] | None] = _Validator(added="1.2") """:external:ref:`core-metadata-obsoletes-dist`""" + import_names: _Validator[list[str] | None] = _Validator(added="2.5") + """:external:ref:`core-metadata-import-name`""" + import_namespaces: _Validator[list[str] | None] = _Validator(added="2.5") + """:external:ref:`core-metadata-import-namespace`""" requires: _Validator[list[str] | None] = _Validator(added="1.1") """``Requires`` (deprecated)""" provides: _Validator[list[str] | None] = _Validator(added="1.1") """``Provides`` (deprecated)""" obsoletes: _Validator[list[str] | None] = _Validator(added="1.1") """``Obsoletes`` (deprecated)""" + + def as_rfc822(self) -> RFC822Message: + """ + Return an RFC822 message with the metadata. + """ + message = RFC822Message() + self._write_metadata(message) + return message + + def _write_metadata(self, message: RFC822Message) -> None: + """ + Return an RFC822 message with the metadata. + """ + for name, validator in self.__class__.__dict__.items(): + if isinstance(validator, _Validator) and name != "description": + value = getattr(self, name) + email_name = _RAW_TO_EMAIL_MAPPING[name] + if value is not None: + if email_name == "project-url": + for label, url in value.items(): + message[email_name] = f"{label}, {url}" + elif email_name == "keywords": + message[email_name] = ",".join(value) + elif email_name == "import-name" and value == []: + message[email_name] = "" + elif isinstance(value, list): + for item in value: + message[email_name] = str(item) + else: + message[email_name] = str(value) + + # The description is a special case because it is in the body of the message. + if self.description is not None: + message.set_payload(self.description) diff --git a/server/libs/packaging/pylock.py b/server/libs/packaging/pylock.py new file mode 100644 index 0000000..84e2537 --- /dev/null +++ b/server/libs/packaging/pylock.py @@ -0,0 +1,905 @@ +from __future__ import annotations + +import dataclasses +import logging +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Protocol, + TypeVar, + cast, +) +from urllib.parse import urlparse + +from .markers import Environment, Marker, default_environment +from .specifiers import SpecifierSet +from .tags import create_compatible_tags_selector, sys_tags +from .utils import ( + NormalizedName, + is_normalized_name, + parse_sdist_filename, + parse_wheel_filename, +) +from .version import Version + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Collection, Iterator + from pathlib import Path + + from typing_extensions import Self + + from .tags import Tag + +_logger = logging.getLogger(__name__) + +__all__ = [ + "Package", + "PackageArchive", + "PackageDirectory", + "PackageSdist", + "PackageVcs", + "PackageWheel", + "Pylock", + "PylockUnsupportedVersionError", + "PylockValidationError", + "is_valid_pylock_path", +] + + +def __dir__() -> list[str]: + return __all__ + + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") + + +class _FromMappingProtocol(Protocol): # pragma: no cover + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: ... + + +_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol) + + +_PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$") + + +def is_valid_pylock_path(path: Path) -> bool: + """Check if the given path is a valid pylock file path.""" + return path.name == "pylock.toml" or bool(_PYLOCK_FILE_NAME_RE.match(path.name)) + + +def _toml_key(key: str) -> str: + return key.replace("_", "-") + + +def _toml_value(key: str, value: Any) -> Any: # noqa: ANN401 + if isinstance(value, (Version, Marker, SpecifierSet)): + return str(value) + if isinstance(value, Sequence) and key == "environments": + return [str(v) for v in value] + return value + + +def _toml_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]: + return { + _toml_key(key): _toml_value(key, value) + for key, value in data + if value is not None + } + + +def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None: + """Get a value from the dictionary and verify it's the expected type.""" + if (value := d.get(key)) is None: + return None + if not isinstance(value, expected_type): + raise PylockValidationError( + f"Unexpected type {type(value).__name__} " + f"(expected {expected_type.__name__})", + context=key, + ) + return value + + +def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T: + """Get a required value from the dictionary and verify it's the expected type.""" + if (value := _get(d, expected_type, key)) is None: + raise _PylockRequiredKeyError(key) + return value + + +def _get_sequence( + d: Mapping[str, Any], expected_item_type: type[_T], key: str +) -> Sequence[_T] | None: + """Get a list value from the dictionary and verify it's the expected items type.""" + if (value := _get(d, Sequence, key)) is None: # type: ignore[type-abstract] + return None + if isinstance(value, (str, bytes)): + # special case: str and bytes are Sequences, but we want to reject it + raise PylockValidationError( + f"Unexpected type {type(value).__name__} (expected Sequence)", + context=key, + ) + for i, item in enumerate(value): + if not isinstance(item, expected_item_type): + raise PylockValidationError( + f"Unexpected type {type(item).__name__} " + f"(expected {expected_item_type.__name__})", + context=f"{key}[{i}]", + ) + return value + + +def _get_as( + d: Mapping[str, Any], + expected_type: type[_T], + target_type: Callable[[_T], _T2], + key: str, +) -> _T2 | None: + """Get a value from the dictionary, verify it's the expected type, + and convert to the target type. + + This assumes the target_type constructor accepts the value. + """ + if (value := _get(d, expected_type, key)) is None: + return None + try: + return target_type(value) + except Exception as e: + raise PylockValidationError(e, context=key) from e + + +def _get_required_as( + d: Mapping[str, Any], + expected_type: type[_T], + target_type: Callable[[_T], _T2], + key: str, +) -> _T2: + """Get a required value from the dict, verify it's the expected type, + and convert to the target type.""" + if (value := _get_as(d, expected_type, target_type, key)) is None: + raise _PylockRequiredKeyError(key) + return value + + +def _get_sequence_as( + d: Mapping[str, Any], + expected_item_type: type[_T], + target_item_type: Callable[[_T], _T2], + key: str, +) -> list[_T2] | None: + """Get list value from dictionary and verify expected items type.""" + if (value := _get_sequence(d, expected_item_type, key)) is None: + return None + result = [] + try: + for item in value: + typed_item = target_item_type(item) + result.append(typed_item) + except Exception as e: + raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e + return result + + +def _get_object( + d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str +) -> _FromMappingProtocolT | None: + """Get a dictionary value from the dictionary and convert it to a dataclass.""" + if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract] + return None + try: + return target_type._from_dict(value) + except Exception as e: + raise PylockValidationError(e, context=key) from e + + +def _get_sequence_of_objects( + d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str +) -> list[_FromMappingProtocolT] | None: + """Get a list value from the dictionary and convert its items to a dataclass.""" + if (value := _get_sequence(d, Mapping, key)) is None: # type: ignore[type-abstract] + return None + result: list[_FromMappingProtocolT] = [] + try: + for item in value: + typed_item = target_item_type._from_dict(item) + result.append(typed_item) + except Exception as e: + raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e + return result + + +def _get_required_sequence_of_objects( + d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str +) -> Sequence[_FromMappingProtocolT]: + """Get a required list value from the dictionary and convert its items to a + dataclass.""" + if (result := _get_sequence_of_objects(d, target_item_type, key)) is None: + raise _PylockRequiredKeyError(key) + return result + + +def _validate_normalized_name(name: str) -> NormalizedName: + """Validate that a string is a NormalizedName.""" + if not is_normalized_name(name): + raise PylockValidationError(f"Name {name!r} is not normalized") + return NormalizedName(name) + + +def _validate_path_url(path: str | None, url: str | None) -> None: + if not path and not url: + raise PylockValidationError("path or url must be provided") + + +def _path_name(path: str | None) -> str | None: + if not path: + return None + # If the path is relative it MAY use POSIX-style path separators explicitly + # for portability + if "/" in path: + return path.rsplit("/", 1)[-1] + elif "\\" in path: + return path.rsplit("\\", 1)[-1] + else: + return path + + +def _url_name(url: str | None) -> str | None: + if not url: + return None + url_path = urlparse(url).path + return url_path.rsplit("/", 1)[-1] + + +def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]: + if not hashes: + raise PylockValidationError("At least one hash must be provided") + if not all(isinstance(hash_val, str) for hash_val in hashes.values()): + raise PylockValidationError("Hash values must be strings") + return hashes + + +class PylockValidationError(Exception): + """Raised when when input data is not spec-compliant.""" + + context: str | None = None + message: str + + def __init__( + self, + cause: str | Exception, + *, + context: str | None = None, + ) -> None: + if isinstance(cause, PylockValidationError): + if cause.context: + self.context = ( + f"{context}.{cause.context}" if context else cause.context + ) + else: + self.context = context + self.message = cause.message + else: + self.context = context + self.message = str(cause) + + def __str__(self) -> str: + if self.context: + return f"{self.message} in {self.context!r}" + return self.message + + +class _PylockRequiredKeyError(PylockValidationError): + def __init__(self, key: str) -> None: + super().__init__("Missing required value", context=key) + + +class PylockUnsupportedVersionError(PylockValidationError): + """Raised when encountering an unsupported `lock_version`.""" + + +class PylockSelectError(Exception): + """Base exception for errors raised by :meth:`Pylock.select`.""" + + +@dataclass(frozen=True, init=False) +class PackageVcs: + type: str + url: str | None = None + path: str | None = None + requested_revision: str | None = None + commit_id: str # type: ignore[misc] + subdirectory: str | None = None + + def __init__( + self, + *, + type: str, + url: str | None = None, + path: str | None = None, + requested_revision: str | None = None, + commit_id: str, + subdirectory: str | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "type", type) + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "requested_revision", requested_revision) + object.__setattr__(self, "commit_id", commit_id) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_vcs = cls( + type=_get_required(d, str, "type"), + url=_get(d, str, "url"), + path=_get(d, str, "path"), + requested_revision=_get(d, str, "requested-revision"), + commit_id=_get_required(d, str, "commit-id"), + subdirectory=_get(d, str, "subdirectory"), + ) + _validate_path_url(package_vcs.path, package_vcs.url) + return package_vcs + + +@dataclass(frozen=True, init=False) +class PackageDirectory: + path: str + editable: bool | None = None + subdirectory: str | None = None + + def __init__( + self, + *, + path: str, + editable: bool | None = None, + subdirectory: str | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "path", path) + object.__setattr__(self, "editable", editable) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + return cls( + path=_get_required(d, str, "path"), + editable=_get(d, bool, "editable"), + subdirectory=_get(d, str, "subdirectory"), + ) + + +@dataclass(frozen=True, init=False) +class PackageArchive: + url: str | None = None + path: str | None = None + size: int | None = None + upload_time: datetime | None = None + hashes: Mapping[str, str] # type: ignore[misc] + subdirectory: str | None = None + + def __init__( + self, + *, + url: str | None = None, + path: str | None = None, + size: int | None = None, + upload_time: datetime | None = None, + hashes: Mapping[str, str], + subdirectory: str | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "size", size) + object.__setattr__(self, "upload_time", upload_time) + object.__setattr__(self, "hashes", hashes) + object.__setattr__(self, "subdirectory", subdirectory) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_archive = cls( + url=_get(d, str, "url"), + path=_get(d, str, "path"), + size=_get(d, int, "size"), + upload_time=_get(d, datetime, "upload-time"), + hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] + subdirectory=_get(d, str, "subdirectory"), + ) + _validate_path_url(package_archive.path, package_archive.url) + return package_archive + + +@dataclass(frozen=True, init=False) +class PackageSdist: + name: str | None = None + upload_time: datetime | None = None + url: str | None = None + path: str | None = None + size: int | None = None + hashes: Mapping[str, str] # type: ignore[misc] + + def __init__( + self, + *, + name: str | None = None, + upload_time: datetime | None = None, + url: str | None = None, + path: str | None = None, + size: int | None = None, + hashes: Mapping[str, str], + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "name", name) + object.__setattr__(self, "upload_time", upload_time) + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "size", size) + object.__setattr__(self, "hashes", hashes) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_sdist = cls( + name=_get(d, str, "name"), + upload_time=_get(d, datetime, "upload-time"), + url=_get(d, str, "url"), + path=_get(d, str, "path"), + size=_get(d, int, "size"), + hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] + ) + _validate_path_url(package_sdist.path, package_sdist.url) + return package_sdist + + @property + def filename(self) -> str: + """Get the filename of the sdist.""" + filename = self.name or _path_name(self.path) or _url_name(self.url) + if not filename: + raise PylockValidationError("Cannot determine sdist filename") + return filename + + +@dataclass(frozen=True, init=False) +class PackageWheel: + name: str | None = None + upload_time: datetime | None = None + url: str | None = None + path: str | None = None + size: int | None = None + hashes: Mapping[str, str] # type: ignore[misc] + + def __init__( + self, + *, + name: str | None = None, + upload_time: datetime | None = None, + url: str | None = None, + path: str | None = None, + size: int | None = None, + hashes: Mapping[str, str], + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "name", name) + object.__setattr__(self, "upload_time", upload_time) + object.__setattr__(self, "url", url) + object.__setattr__(self, "path", path) + object.__setattr__(self, "size", size) + object.__setattr__(self, "hashes", hashes) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package_wheel = cls( + name=_get(d, str, "name"), + upload_time=_get(d, datetime, "upload-time"), + url=_get(d, str, "url"), + path=_get(d, str, "path"), + size=_get(d, int, "size"), + hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract] + ) + _validate_path_url(package_wheel.path, package_wheel.url) + return package_wheel + + @property + def filename(self) -> str: + """Get the filename of the wheel.""" + filename = self.name or _path_name(self.path) or _url_name(self.url) + if not filename: + raise PylockValidationError("Cannot determine wheel filename") + return filename + + +@dataclass(frozen=True, init=False) +class Package: + name: NormalizedName + version: Version | None = None + marker: Marker | None = None + requires_python: SpecifierSet | None = None + dependencies: Sequence[Mapping[str, Any]] | None = None + vcs: PackageVcs | None = None + directory: PackageDirectory | None = None + archive: PackageArchive | None = None + index: str | None = None + sdist: PackageSdist | None = None + wheels: Sequence[PackageWheel] | None = None + attestation_identities: Sequence[Mapping[str, Any]] | None = None + tool: Mapping[str, Any] | None = None + + def __init__( + self, + *, + name: NormalizedName, + version: Version | None = None, + marker: Marker | None = None, + requires_python: SpecifierSet | None = None, + dependencies: Sequence[Mapping[str, Any]] | None = None, + vcs: PackageVcs | None = None, + directory: PackageDirectory | None = None, + archive: PackageArchive | None = None, + index: str | None = None, + sdist: PackageSdist | None = None, + wheels: Sequence[PackageWheel] | None = None, + attestation_identities: Sequence[Mapping[str, Any]] | None = None, + tool: Mapping[str, Any] | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "name", name) + object.__setattr__(self, "version", version) + object.__setattr__(self, "marker", marker) + object.__setattr__(self, "requires_python", requires_python) + object.__setattr__(self, "dependencies", dependencies) + object.__setattr__(self, "vcs", vcs) + object.__setattr__(self, "directory", directory) + object.__setattr__(self, "archive", archive) + object.__setattr__(self, "index", index) + object.__setattr__(self, "sdist", sdist) + object.__setattr__(self, "wheels", wheels) + object.__setattr__(self, "attestation_identities", attestation_identities) + object.__setattr__(self, "tool", tool) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + package = cls( + name=_get_required_as(d, str, _validate_normalized_name, "name"), + version=_get_as(d, str, Version, "version"), + requires_python=_get_as(d, str, SpecifierSet, "requires-python"), + dependencies=_get_sequence(d, Mapping, "dependencies"), # type: ignore[type-abstract] + marker=_get_as(d, str, Marker, "marker"), + vcs=_get_object(d, PackageVcs, "vcs"), + directory=_get_object(d, PackageDirectory, "directory"), + archive=_get_object(d, PackageArchive, "archive"), + index=_get(d, str, "index"), + sdist=_get_object(d, PackageSdist, "sdist"), + wheels=_get_sequence_of_objects(d, PackageWheel, "wheels"), + attestation_identities=_get_sequence(d, Mapping, "attestation-identities"), # type: ignore[type-abstract] + tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract] + ) + distributions = bool(package.sdist) + len(package.wheels or []) + direct_urls = ( + bool(package.vcs) + bool(package.directory) + bool(package.archive) + ) + if distributions > 0 and direct_urls > 0: + raise PylockValidationError( + "None of vcs, directory, archive must be set if sdist or wheels are set" + ) + if distributions == 0 and direct_urls != 1: + raise PylockValidationError( + "Exactly one of vcs, directory, archive must be set " + "if sdist and wheels are not set" + ) + for i, wheel in enumerate(package.wheels or []): + try: + (name, version, _, _) = parse_wheel_filename(wheel.filename) + except Exception as e: + raise PylockValidationError( + f"Invalid wheel filename {wheel.filename!r}", + context=f"wheels[{i}]", + ) from e + if name != package.name: + raise PylockValidationError( + f"Name in {wheel.filename!r} is not consistent with " + f"package name {package.name!r}", + context=f"wheels[{i}]", + ) + if package.version and version != package.version: + raise PylockValidationError( + f"Version in {wheel.filename!r} is not consistent with " + f"package version {str(package.version)!r}", + context=f"wheels[{i}]", + ) + if package.sdist: + try: + name, version = parse_sdist_filename(package.sdist.filename) + except Exception as e: + raise PylockValidationError( + f"Invalid sdist filename {package.sdist.filename!r}", + context="sdist", + ) from e + if name != package.name: + raise PylockValidationError( + f"Name in {package.sdist.filename!r} is not consistent with " + f"package name {package.name!r}", + context="sdist", + ) + if package.version and version != package.version: + raise PylockValidationError( + f"Version in {package.sdist.filename!r} is not consistent with " + f"package version {str(package.version)!r}", + context="sdist", + ) + try: + for i, attestation_identity in enumerate( # noqa: B007 + package.attestation_identities or [] + ): + _get_required(attestation_identity, str, "kind") + except Exception as e: + raise PylockValidationError( + e, context=f"attestation-identities[{i}]" + ) from e + return package + + @property + def is_direct(self) -> bool: + return not (self.sdist or self.wheels) + + +@dataclass(frozen=True, init=False) +class Pylock: + """A class representing a pylock file.""" + + lock_version: Version + environments: Sequence[Marker] | None = None + requires_python: SpecifierSet | None = None + extras: Sequence[NormalizedName] | None = None + dependency_groups: Sequence[str] | None = None + default_groups: Sequence[str] | None = None + created_by: str # type: ignore[misc] + packages: Sequence[Package] # type: ignore[misc] + tool: Mapping[str, Any] | None = None + + def __init__( + self, + *, + lock_version: Version, + environments: Sequence[Marker] | None = None, + requires_python: SpecifierSet | None = None, + extras: Sequence[NormalizedName] | None = None, + dependency_groups: Sequence[str] | None = None, + default_groups: Sequence[str] | None = None, + created_by: str, + packages: Sequence[Package], + tool: Mapping[str, Any] | None = None, + ) -> None: + # In Python 3.10+ make dataclass kw_only=True and remove __init__ + object.__setattr__(self, "lock_version", lock_version) + object.__setattr__(self, "environments", environments) + object.__setattr__(self, "requires_python", requires_python) + object.__setattr__(self, "extras", extras) + object.__setattr__(self, "dependency_groups", dependency_groups) + object.__setattr__(self, "default_groups", default_groups) + object.__setattr__(self, "created_by", created_by) + object.__setattr__(self, "packages", packages) + object.__setattr__(self, "tool", tool) + + @classmethod + def _from_dict(cls, d: Mapping[str, Any]) -> Self: + pylock = cls( + lock_version=_get_required_as(d, str, Version, "lock-version"), + environments=_get_sequence_as(d, str, Marker, "environments"), + extras=_get_sequence_as(d, str, _validate_normalized_name, "extras"), + dependency_groups=_get_sequence(d, str, "dependency-groups"), + default_groups=_get_sequence(d, str, "default-groups"), + created_by=_get_required(d, str, "created-by"), + requires_python=_get_as(d, str, SpecifierSet, "requires-python"), + packages=_get_required_sequence_of_objects(d, Package, "packages"), + tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract] + ) + if not Version("1") <= pylock.lock_version < Version("2"): + raise PylockUnsupportedVersionError( + f"pylock version {pylock.lock_version} is not supported" + ) + if pylock.lock_version > Version("1.0"): + _logger.warning( + "pylock minor version %s is not supported", pylock.lock_version + ) + return pylock + + @classmethod + def from_dict(cls, d: Mapping[str, Any], /) -> Self: + """Create and validate a Pylock instance from a TOML dictionary. + + Raises :class:`PylockValidationError` if the input data is not + spec-compliant. + """ + return cls._from_dict(d) + + def to_dict(self) -> Mapping[str, Any]: + """Convert the Pylock instance to a TOML dictionary.""" + return dataclasses.asdict(self, dict_factory=_toml_dict_factory) + + def validate(self) -> None: + """Validate the Pylock instance against the specification. + + Raises :class:`PylockValidationError` otherwise.""" + self.from_dict(self.to_dict()) + + def select( + self, + *, + environment: Environment | None = None, + tags: Sequence[Tag] | None = None, + extras: Collection[str] | None = None, + dependency_groups: Collection[str] | None = None, + ) -> Iterator[ + tuple[ + Package, + PackageVcs + | PackageDirectory + | PackageArchive + | PackageWheel + | PackageSdist, + ] + ]: + """Select what to install from the lock file. + + The *environment* and *tags* parameters represent the environment being + selected for. If unspecified, ``packaging.markers.default_environment()`` and + ``packaging.tags.sys_tags()`` are used. + + The *extras* parameter represents the extras to install. + + The *dependency_groups* parameter represents the groups to install. If + unspecified, the default groups are used. + + This method must be used on valid Pylock instances (i.e. one obtained + from :meth:`Pylock.from_dict` or if constructed manually, after calling + :meth:`Pylock.validate`). + """ + compatible_tags_selector = create_compatible_tags_selector(tags or sys_tags()) + + # #. Gather the extras and dependency groups to install and set ``extras`` and + # ``dependency_groups`` for marker evaluation, respectively. + # + # #. ``extras`` SHOULD be set to the empty set by default. + # #. ``dependency_groups`` SHOULD be the set created from + # :ref:`pylock-default-groups` by default. + env = cast( + "dict[str, str | frozenset[str]]", + dict( + environment or {}, # Marker.evaluate will fill-up + extras=frozenset(extras or []), + dependency_groups=frozenset( + (self.default_groups or []) + if dependency_groups is None # to allow selecting no group + else dependency_groups + ), + ), + ) + env_python_full_version = ( + environment["python_full_version"] + if environment + else default_environment()["python_full_version"] + ) + + # #. Check if the metadata version specified by :ref:`pylock-lock-version` is + # supported; an error or warning MUST be raised as appropriate. + # Covered by lock.validate() which is a precondition for this method. + + # #. If :ref:`pylock-requires-python` is specified, check that the environment + # being installed for meets the requirement; an error MUST be raised if it is + # not met. + if self.requires_python and not self.requires_python.contains( + env_python_full_version, + ): + raise PylockSelectError( + f"python_full_version {env_python_full_version!r} " + f"in provided environment does not satisfy the Python version " + f"requirement {str(self.requires_python)!r}" + ) + + # #. If :ref:`pylock-environments` is specified, check that at least one of the + # environment marker expressions is satisfied; an error MUST be raised if no + # expression is satisfied. + if self.environments: + for env_marker in self.environments: + if env_marker.evaluate( + cast("dict[str, str]", environment or {}), context="requirement" + ): + break + else: + raise PylockSelectError( + "Provided environment does not satisfy any of the " + "environments specified in the lock file" + ) + + # #. For each package listed in :ref:`pylock-packages`: + selected_packages_by_name: dict[str, tuple[int, Package]] = {} + for package_index, package in enumerate(self.packages): + # #. If :ref:`pylock-packages-marker` is specified, check if it is + # satisfied;if it isn't, skip to the next package. + if package.marker and not package.marker.evaluate(env, context="lock_file"): + continue + + # #. If :ref:`pylock-packages-requires-python` is specified, check if it is + # satisfied; an error MUST be raised if it isn't. + if package.requires_python and not package.requires_python.contains( + env_python_full_version, + ): + raise PylockSelectError( + f"python_full_version {env_python_full_version!r} " + f"in provided environment does not satisfy the Python version " + f"requirement {str(package.requires_python)!r} for package " + f"{package.name!r} at packages[{package_index}]" + ) + + # #. Check that no other conflicting instance of the package has been slated + # to be installed; an error about the ambiguity MUST be raised otherwise. + if package.name in selected_packages_by_name: + raise PylockSelectError( + f"Multiple packages with the name {package.name!r} are " + f"selected at packages[{package_index}] and " + f"packages[{selected_packages_by_name[package.name][0]}]" + ) + + # #. Check that the source of the package is specified appropriately (i.e. + # there are no conflicting sources in the package entry); + # an error MUST be raised if any issues are found. + # Covered by lock.validate() which is a precondition for this method. + + # #. Add the package to the set of packages to install. + selected_packages_by_name[package.name] = (package_index, package) + + # #. For each package to be installed: + for package_index, package in selected_packages_by_name.values(): + # - If :ref:`pylock-packages-vcs` is set: + if package.vcs is not None: + yield package, package.vcs + + # - Else if :ref:`pylock-packages-directory` is set: + elif package.directory is not None: + yield package, package.directory + + # - Else if :ref:`pylock-packages-archive` is set: + elif package.archive is not None: + yield package, package.archive + + # - Else if there are entries for :ref:`pylock-packages-wheels`: + elif package.wheels: + # #. Look for the appropriate wheel file based on + # :ref:`pylock-packages-wheels-name`; if one is not found then move + # on to :ref:`pylock-packages-sdist` or an error MUST be raised about + # a lack of source for the project. + best_wheel = next( + compatible_tags_selector( + (wheel, parse_wheel_filename(wheel.filename)[-1]) + for wheel in package.wheels + ), + None, + ) + if best_wheel: + yield package, best_wheel + elif package.sdist is not None: + yield package, package.sdist + else: + raise PylockSelectError( + f"No wheel found matching the provided tags " + f"for package {package.name!r} " + f"at packages[{package_index}], " + f"and no sdist available as a fallback" + ) + + # - Else if no :ref:`pylock-packages-wheels` file is found or + # :ref:`pylock-packages-sdist` is solely set: + elif package.sdist is not None: + yield package, package.sdist + + else: + # Covered by lock.validate() which is a precondition for this method. + raise NotImplementedError # pragma: no cover diff --git a/server/libs/packaging/requirements.py b/server/libs/packaging/requirements.py index 4e068c9..5892aee 100644 --- a/server/libs/packaging/requirements.py +++ b/server/libs/packaging/requirements.py @@ -3,7 +3,7 @@ # for complete details. from __future__ import annotations -from typing import Any, Iterator +from typing import Iterator from ._parser import parse_requirement as _parse_requirement from ._tokenizer import ParserSyntaxError @@ -11,6 +11,15 @@ from .markers import Marker, _normalize_extra_values from .specifiers import SpecifierSet from .utils import canonicalize_name +__all__ = [ + "InvalidRequirement", + "Requirement", +] + + +def __dir__() -> list[str]: + return __all__ + class InvalidRequirement(ValueError): """ @@ -24,6 +33,16 @@ class Requirement: Parse a given requirement string into its parts, such as name, specifier, URL, and extras. Raises InvalidRequirement on a badly-formed requirement string. + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging releases. + + .. versionchanged:: 26.2 + + Added a stable pickle format. Pickles created with packaging 26.2+ can + be unpickled with future releases. Backward compatibility with pickles + from packaging < 26.2 is supported but may be removed in a future + release. """ # TODO: Can we test whether something is contained within a requirement? @@ -57,28 +76,47 @@ class Requirement: yield str(self.specifier) if self.url: - yield f"@ {self.url}" + yield f" @ {self.url}" if self.marker: yield " " if self.marker: yield f"; {self.marker}" + def __getstate__(self) -> str: + # Return the requirement string for compactness and stability. + # Re-parsed on load to reconstruct all fields. + return str(self) + + def __setstate__(self, state: object) -> None: + if isinstance(state, str): + # New format (26.2+): just the requirement string. + try: + tmp = Requirement(state) + except InvalidRequirement as exc: + raise TypeError(f"Cannot restore Requirement from {state!r}") from exc + self.name = tmp.name + self.url = tmp.url + self.extras = tmp.extras + self.specifier = tmp.specifier + self.marker = tmp.marker + return + if isinstance(state, dict): + # Old format (packaging <= 26.1, no __slots__): plain __dict__. + self.__dict__.update(state) + return + raise TypeError(f"Cannot restore Requirement from {state!r}") + def __str__(self) -> str: return "".join(self._iter_parts(self.name)) def __repr__(self) -> str: - return f"" + return f"<{self.__class__.__name__}({str(self)!r})>" def __hash__(self) -> int: - return hash( - ( - self.__class__.__name__, - *self._iter_parts(canonicalize_name(self.name)), - ) - ) + return hash(tuple(self._iter_parts(canonicalize_name(self.name)))) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, Requirement): return NotImplemented diff --git a/server/libs/packaging/specifiers.py b/server/libs/packaging/specifiers.py index c844804..b165dc0 100644 --- a/server/libs/packaging/specifiers.py +++ b/server/libs/packaging/specifiers.py @@ -11,24 +11,336 @@ from __future__ import annotations import abc +import enum +import functools import itertools import re -from typing import Callable, Iterable, Iterator, TypeVar, Union +import sys +import typing +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Final, + Iterable, + Iterator, + Sequence, + TypeVar, + Union, +) from .utils import canonicalize_version -from .version import Version +from .version import InvalidVersion, Version +if sys.version_info >= (3, 10): + from typing import TypeGuard # pragma: no cover +elif TYPE_CHECKING: + from typing_extensions import TypeGuard + +__all__ = [ + "BaseSpecifier", + "InvalidSpecifier", + "Specifier", + "SpecifierSet", +] + + +def __dir__() -> list[str]: + return __all__ + + +def _validate_spec(spec: object, /) -> TypeGuard[tuple[str, str]]: + return ( + isinstance(spec, tuple) + and len(spec) == 2 + and isinstance(spec[0], str) + and isinstance(spec[1], str) + ) + + +def _validate_pre(pre: object, /) -> TypeGuard[bool | None]: + return pre is None or isinstance(pre, bool) + + +T = TypeVar("T") UnparsedVersion = Union[Version, str] UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) CallableOperator = Callable[[Version, str], bool] +# The smallest possible PEP 440 version. No valid version is less than this. +_MIN_VERSION: Final[Version] = Version("0.dev0") -def _coerce_version(version: UnparsedVersion) -> Version: + +def _trim_release(release: tuple[int, ...]) -> tuple[int, ...]: + """Strip trailing zeros from a release tuple for normalized comparison.""" + end = len(release) + while end > 1 and release[end - 1] == 0: + end -= 1 + return release if end == len(release) else release[:end] + + +class _BoundaryKind(enum.Enum): + """Where a boundary marker sits in the version ordering.""" + + AFTER_LOCALS = enum.auto() # after V+local, before V.post0 + AFTER_POSTS = enum.auto() # after V.postN, before next release + + +@functools.total_ordering +class _BoundaryVersion: + """A point on the version line between two real PEP 440 versions. + + Some specifier semantics imply boundaries between real versions: + ``<=1.0`` includes ``1.0+local`` and ``>1.0`` excludes + ``1.0.post0``. No real :class:`Version` falls on those boundaries, + so this class creates values that sort between the real versions + on either side. + + Two kinds exist, shown relative to a base version V:: + + V < V+local < AFTER_LOCALS(V) < V.post0 < AFTER_POSTS(V) + + ``AFTER_LOCALS`` sits after V and every V+local, but before + V.post0. Upper bound of ``<=V``, ``==V``, ``!=V``. + + ``AFTER_POSTS`` sits after every V.postN, but before the next + release segment. Lower bound of ``>V`` (final or pre-release V) + to exclude post-releases per PEP 440. + """ + + __slots__ = ("_kind", "_trimmed_release", "version") + + def __init__(self, version: Version, kind: _BoundaryKind) -> None: + self.version = version + self._kind = kind + self._trimmed_release = _trim_release(version.release) + + def _is_family(self, other: Version) -> bool: + """Is ``other`` a version that this boundary sorts above?""" + v = self.version + if not ( + other.epoch == v.epoch + and _trim_release(other.release) == self._trimmed_release + and other.pre == v.pre + ): + return False + if self._kind == _BoundaryKind.AFTER_LOCALS: + # Local family: exact same public version (any local label). + return other.post == v.post and other.dev == v.dev + # Post family: same base + any post-release (or identical). + return other.dev == v.dev or other.post is not None + + def __eq__(self, other: object) -> bool: + if isinstance(other, _BoundaryVersion): + return self.version == other.version and self._kind == other._kind + return NotImplemented + + def __lt__(self, other: _BoundaryVersion | Version) -> bool: + if isinstance(other, _BoundaryVersion): + if self.version != other.version: + return self.version < other.version + return self._kind.value < other._kind.value + return not self._is_family(other) and self.version < other + + def __hash__(self) -> int: + return hash((self.version, self._kind)) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.version!r}, {self._kind.name})" + + +@functools.total_ordering +class _LowerBound: + """Lower bound of a version range. + + A version *v* of ``None`` means unbounded below (-inf). + At equal versions, ``[v`` sorts before ``(v`` because an inclusive + bound starts earlier. + """ + + __slots__ = ("inclusive", "version") + + def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: + self.version = version + self.inclusive = inclusive + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _LowerBound): + return NotImplemented # pragma: no cover + return self.version == other.version and self.inclusive == other.inclusive + + def __lt__(self, other: _LowerBound) -> bool: + if not isinstance(other, _LowerBound): # pragma: no cover + return NotImplemented + # -inf < anything (except -inf). + if self.version is None: + return other.version is not None + if other.version is None: + return False + if self.version != other.version: + return self.version < other.version + # [v < (v: inclusive starts earlier. + return self.inclusive and not other.inclusive + + def __hash__(self) -> int: + return hash((self.version, self.inclusive)) + + def __repr__(self) -> str: + bracket = "[" if self.inclusive else "(" + return f"<{self.__class__.__name__} {bracket}{self.version!r}>" + + +@functools.total_ordering +class _UpperBound: + """Upper bound of a version range. + + A version *v* of ``None`` means unbounded above (+inf). + At equal versions, ``v)`` sorts before ``v]`` because an exclusive + bound ends earlier. + """ + + __slots__ = ("inclusive", "version") + + def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None: + self.version = version + self.inclusive = inclusive + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _UpperBound): + return NotImplemented # pragma: no cover + return self.version == other.version and self.inclusive == other.inclusive + + def __lt__(self, other: _UpperBound) -> bool: + if not isinstance(other, _UpperBound): # pragma: no cover + return NotImplemented + # Nothing < +inf (except +inf itself). + if self.version is None: + return False + if other.version is None: + return True + if self.version != other.version: + return self.version < other.version + # v) < v]: exclusive ends earlier. + return not self.inclusive and other.inclusive + + def __hash__(self) -> int: + return hash((self.version, self.inclusive)) + + def __repr__(self) -> str: + bracket = "]" if self.inclusive else ")" + return f"<{self.__class__.__name__} {self.version!r}{bracket}>" + + +if typing.TYPE_CHECKING: + _VersionOrBoundary = Union[Version, _BoundaryVersion, None] + + #: A single contiguous version range, represented as a + #: (lower bound, upper bound) pair. + _VersionRange = tuple[_LowerBound, _UpperBound] + +_NEG_INF = _LowerBound(None, False) +_POS_INF = _UpperBound(None, False) +_FULL_RANGE: tuple[_VersionRange] = ((_NEG_INF, _POS_INF),) + + +def _range_is_empty(lower: _LowerBound, upper: _UpperBound) -> bool: + """True when the range defined by *lower* and *upper* contains no versions.""" + if lower.version is None or upper.version is None: + return False + if lower.version == upper.version: + return not (lower.inclusive and upper.inclusive) + return lower.version > upper.version + + +def _intersect_ranges( + left: Sequence[_VersionRange], + right: Sequence[_VersionRange], +) -> list[_VersionRange]: + """Intersect two sorted, non-overlapping range lists (two-pointer merge).""" + result: list[_VersionRange] = [] + left_index = right_index = 0 + while left_index < len(left) and right_index < len(right): + left_lower, left_upper = left[left_index] + right_lower, right_upper = right[right_index] + + lower = max(left_lower, right_lower) + upper = min(left_upper, right_upper) + + if not _range_is_empty(lower, upper): + result.append((lower, upper)) + + # Advance whichever side has the smaller upper bound. + if left_upper < right_upper: + left_index += 1 + else: + right_index += 1 + + return result + + +def _next_prefix_dev0(version: Version) -> Version: + """Smallest version in the next prefix: 1.2 -> 1.3.dev0.""" + release = (*version.release[:-1], version.release[-1] + 1) + return Version.from_parts(epoch=version.epoch, release=release, dev=0) + + +def _base_dev0(version: Version) -> Version: + """The .dev0 of a version's base release: 1.2 -> 1.2.dev0.""" + return Version.from_parts(epoch=version.epoch, release=version.release, dev=0) + + +def _coerce_version(version: UnparsedVersion) -> Version | None: if not isinstance(version, Version): - version = Version(version) + try: + version = Version(version) + except InvalidVersion: + return None return version +def _public_version(version: Version) -> Version: + if version.local is None: + return version + return version.__replace__(local=None) + + +def _post_base(version: Version) -> Version: + """The version that *version* is a post-release of. + + 1.0.post1 -> 1.0, 1.0a1.post0 -> 1.0a1, 1.0.post0.dev1 -> 1.0. + """ + return version.__replace__(post=None, dev=None, local=None) + + +def _earliest_prerelease(version: Version) -> Version: + """Earliest pre-release of *version*. + + 1.2 -> 1.2.dev0, 1.2.post1 -> 1.2.post1.dev0. + """ + return version.__replace__(dev=0, local=None) + + +def _nearest_non_prerelease( + v: _VersionOrBoundary, +) -> Version | None: + """Smallest non-pre-release version at or above *v*, or None.""" + if v is None: + return None + if isinstance(v, _BoundaryVersion): + inner = v.version + if inner.is_prerelease: + # AFTER_LOCALS(1.0a1) -> nearest non-pre is 1.0 + return inner.__replace__(pre=None, dev=None, local=None) + # AFTER_LOCALS(1.0) -> nearest non-pre is 1.0.post0 + # AFTER_LOCALS(1.0.post0) -> nearest non-pre is 1.0.post1 + k = (inner.post + 1) if inner.post is not None else 0 + return inner.__replace__(post=k, local=None) + if not v.is_prerelease: + return v + # Strip pre/dev to get the final or post-release form. + return v.__replace__(pre=None, dev=None, local=None) + + class InvalidSpecifier(ValueError): """ Raised when attempting to create a :class:`Specifier` with a specifier @@ -42,6 +354,14 @@ class InvalidSpecifier(ValueError): class BaseSpecifier(metaclass=abc.ABCMeta): + __slots__ = () + __match_args__ = ("_str",) + + @property + def _str(self) -> str: + """Internal property for match_args""" + return str(self) + @abc.abstractmethod def __str__(self) -> str: """ @@ -73,7 +393,7 @@ class BaseSpecifier(metaclass=abc.ABCMeta): prereleases or it can be set to ``None`` (the default) to use default semantics. """ - @prereleases.setter + @prereleases.setter # noqa: B027 def prereleases(self, value: bool) -> None: """Setter for :attr:`prereleases`. @@ -86,10 +406,29 @@ class BaseSpecifier(metaclass=abc.ABCMeta): Determines if the given item is contained within this specifier. """ + @typing.overload + def filter( + self, + iterable: Iterable[UnparsedVersionVar], + prereleases: bool | None = None, + key: None = ..., + ) -> Iterator[UnparsedVersionVar]: ... + + @typing.overload + def filter( + self, + iterable: Iterable[T], + prereleases: bool | None = None, + key: Callable[[T], UnparsedVersion] = ..., + ) -> Iterator[T]: ... + @abc.abstractmethod def filter( - self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None - ) -> Iterator[UnparsedVersionVar]: + self, + iterable: Iterable[Any], + prereleases: bool | None = None, + key: Callable[[Any], UnparsedVersion] | None = None, + ) -> Iterator[Any]: """ Takes an iterable of items and filters them so that only items which are contained within this specifier are allowed in it. @@ -104,20 +443,35 @@ class Specifier(BaseSpecifier): It is generally not required to instantiate this manually. You should instead prefer to work with :class:`SpecifierSet` instead, which can parse comma-separated version specifiers (which is what package metadata contains). + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging releases. + + .. versionchanged:: 26.2 + + Added a stable pickle format. Pickles created with packaging 26.2+ can + be unpickled with future releases. Backward compatibility with pickles + from packaging < 26.2 is supported but may be removed in a future + release. """ - _operator_regex_str = r""" - (?P(~=|==|!=|<=|>=|<|>|===)) - """ - _version_regex_str = r""" - (?P + __slots__ = ( + "_prereleases", + "_ranges", + "_spec", + "_spec_version", + "_wildcard_split", + ) + + _specifier_regex_str = r""" + (?: (?: # The identity operators allow for an escape hatch that will # do an exact string match of the version you wish to install. # This will not be parsed by PEP 440 and we cannot determine # any semantic meaning from it. This operator is discouraged # but included entirely as an escape hatch. - (?<====) # Only match for the identity operator + === # Only match for the identity operator \s* [^\s;)]* # The arbitrary version can be just about anything, # we match everything except for whitespace, a @@ -129,7 +483,7 @@ class Specifier(BaseSpecifier): # The (non)equality operators allow for wild card and local # versions to be specified so we have to define these two # operators separately to enable that. - (?<===|!=) # Only match for equals and not equals + (?:==|!=) # Only match for equals and not equals \s* v? @@ -141,24 +495,24 @@ class Specifier(BaseSpecifier): (?: \.\* # Wild card syntax of .* | - (?: # pre release + (?a: # pre release [-_\.]? (alpha|beta|preview|pre|a|b|c|rc) [-_\.]? [0-9]* )? - (?: # post release + (?a: # post release (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) )? - (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release - (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local + (?a:[-_\.]?dev[-_\.]?[0-9]*)? # dev release + (?a:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local )? ) | (?: # The compatible operator requires at least two digits in the # release segment. - (?<=~=) # Only match for the compatible operator + (?:~=) # Only match for the compatible operator \s* v? @@ -181,34 +535,31 @@ class Specifier(BaseSpecifier): # (non)equality operators do. Specifically they do not allow # local versions to be specified nor do they allow the prefix # matching wild cards. - (?=|<|>) \s* v? (?:[0-9]+!)? # epoch [0-9]+(?:\.[0-9]+)* # release - (?: # pre release + (?a: # pre release [-_\.]? (alpha|beta|preview|pre|a|b|c|rc) [-_\.]? [0-9]* )? - (?: # post release + (?a: # post release (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) )? - (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release + (?a:[-_\.]?dev[-_\.]?[0-9]*)? # dev release ) ) """ _regex = re.compile( - r"^\s*" + _operator_regex_str + _version_regex_str + r"\s*$", - re.VERBOSE | re.IGNORECASE, + r"\s*" + _specifier_regex_str + r"\s*", re.VERBOSE | re.IGNORECASE ) - _operators = { + _operators: Final = { "~=": "compatible", "==": "equal", "!=": "not_equal", @@ -232,47 +583,224 @@ class Specifier(BaseSpecifier): :raises InvalidSpecifier: If the given specifier is invalid (i.e. bad syntax). """ - match = self._regex.search(spec) - if not match: + if not self._regex.fullmatch(spec): raise InvalidSpecifier(f"Invalid specifier: {spec!r}") - self._spec: tuple[str, str] = ( - match.group("operator").strip(), - match.group("version").strip(), - ) + spec = spec.strip() + if spec.startswith("==="): + operator, version = spec[:3], spec[3:].strip() + elif spec.startswith(("~=", "==", "!=", "<=", ">=")): + operator, version = spec[:2], spec[2:].strip() + else: + operator, version = spec[:1], spec[1:].strip() + + self._spec: tuple[str, str] = (operator, version) # Store whether or not this Specifier should accept prereleases self._prereleases = prereleases - # https://github.com/python/mypy/pull/13475#pullrequestreview-1079784515 - @property # type: ignore[override] - def prereleases(self) -> bool: + # Specifier version cache + self._spec_version: tuple[str, Version] | None = None + + # Populated on first wildcard (==X.*) comparison + self._wildcard_split: tuple[list[str], int] | None = None + + # Version range cache (populated by _to_ranges) + self._ranges: Sequence[_VersionRange] | None = None + + def _get_spec_version(self, version: str) -> Version | None: + """One element cache, as only one spec Version is needed per Specifier.""" + if self._spec_version is not None and self._spec_version[0] == version: + return self._spec_version[1] + + version_specifier = _coerce_version(version) + if version_specifier is None: + return None + + self._spec_version = (version, version_specifier) + return version_specifier + + def _require_spec_version(self, version: str) -> Version: + """Get spec version, asserting it's valid (not for === operator). + + This method should only be called for operators where version + strings are guaranteed to be valid PEP 440 versions (not ===). + """ + spec_version = self._get_spec_version(version) + assert spec_version is not None + return spec_version + + def _to_ranges(self) -> Sequence[_VersionRange]: + """Convert this specifier to sorted, non-overlapping version ranges. + + Each standard operator maps to one or two ranges. ``===`` is + modeled as full range (actual check done separately). Cached. + """ + if self._ranges is not None: + return self._ranges + + op = self.operator + ver_str = self.version + + if op == "===": + self._ranges = _FULL_RANGE + return _FULL_RANGE + + if ver_str.endswith(".*"): + result = self._wildcard_ranges(op, ver_str) + else: + result = self._standard_ranges(op, ver_str) + + self._ranges = result + return result + + def _wildcard_ranges(self, op: str, ver_str: str) -> list[_VersionRange]: + # ==1.2.* -> [1.2.dev0, 1.3.dev0); !=1.2.* -> complement. + base = self._require_spec_version(ver_str[:-2]) + lower = _base_dev0(base) + upper = _next_prefix_dev0(base) + if op == "==": + return [(_LowerBound(lower, True), _UpperBound(upper, False))] + # != + return [ + (_NEG_INF, _UpperBound(lower, False)), + (_LowerBound(upper, True), _POS_INF), + ] + + def _standard_ranges(self, op: str, ver_str: str) -> list[_VersionRange]: + v = self._require_spec_version(ver_str) + + if op == ">=": + return [(_LowerBound(v, True), _POS_INF)] + + if op == "<=": + return [ + ( + _NEG_INF, + _UpperBound(_BoundaryVersion(v, _BoundaryKind.AFTER_LOCALS), True), + ) + ] + + if op == ">": + if v.dev is not None: + # >V.devN: dev versions have no post-releases, so the + # next real version is V.dev(N+1). + lower_ver = v.__replace__(dev=v.dev + 1, local=None) + return [(_LowerBound(lower_ver, True), _POS_INF)] + if v.post is not None: + # >V.postN: next real version is V.post(N+1).dev0. + lower_ver = v.__replace__(post=v.post + 1, dev=0, local=None) + return [(_LowerBound(lower_ver, True), _POS_INF)] + # >V (final or pre-release): skip V+local and all V.postN. + return [ + ( + _LowerBound(_BoundaryVersion(v, _BoundaryKind.AFTER_POSTS), False), + _POS_INF, + ) + ] + + if op == "<": + # bool | None: # If there is an explicit prereleases set for this, then we'll just # blindly use that. if self._prereleases is not None: return self._prereleases - # Look at all of our specifiers and determine if they are inclusive - # operators, and if they are if they are including an explicit - # prerelease. - operator, version = self._spec - if operator in ["==", ">=", "<=", "~=", "===", ">", "<"]: - # The == specifier can include a trailing .*, if it does we - # want to remove before parsing. - if operator == "==" and version.endswith(".*"): - version = version[:-2] + # Only the "!=" operator does not imply prereleases when + # the version in the specifier is a prerelease. + operator, version_str = self._spec + if operator == "!=": + return False - # Parse the version, and if it is a pre-release than this - # specifier allows pre-releases. - if Version(version).is_prerelease: - return True + # The == specifier with trailing .* cannot include prereleases + # e.g. "==1.0a1.*" is not valid. + if operator == "==" and version_str.endswith(".*"): + return False - return False + # "===" can have arbitrary string versions, so we cannot parse + # those, we take prereleases as unknown (None) for those. + version = self._get_spec_version(version_str) + if version is None: + return None + + # For all other operators, use the check if spec Version + # object implies pre-releases. + return version.is_prerelease @prereleases.setter - def prereleases(self, value: bool) -> None: + def prereleases(self, value: bool | None) -> None: self._prereleases = value + def __getstate__(self) -> tuple[tuple[str, str], bool | None]: + # Return state as a 2-item tuple for compactness: + # ((operator, version), prereleases) + # Cache members are excluded and will be recomputed on demand. + return (self._spec, self._prereleases) + + def __setstate__(self, state: object) -> None: + # Always discard cached values - they will be recomputed on demand. + self._spec_version = None + self._wildcard_split = None + self._ranges = None + + if isinstance(state, tuple): + if len(state) == 2: + # New format (26.2+): ((operator, version), prereleases) + spec, prereleases = state + if _validate_spec(spec) and _validate_pre(prereleases): + self._spec = spec + self._prereleases = prereleases + return + if len(state) == 2 and isinstance(state[1], dict): + # Format (packaging 26.0-26.1): (None, {slot: value}). + _, slot_dict = state + spec = slot_dict.get("_spec") + prereleases = slot_dict.get("_prereleases", "invalid") + if _validate_spec(spec) and _validate_pre(prereleases): + self._spec = spec + self._prereleases = prereleases + return + if isinstance(state, dict): + # Old format (packaging <= 25.x, no __slots__): state is a plain dict. + spec = state.get("_spec") + prereleases = state.get("_prereleases", "invalid") + if _validate_spec(spec) and _validate_pre(prereleases): + self._spec = spec + self._prereleases = prereleases + return + + raise TypeError(f"Cannot restore Specifier from {state!r}") + @property def operator(self) -> str: """The operator of this specifier. @@ -321,11 +849,17 @@ class Specifier(BaseSpecifier): @property def _canonical_spec(self) -> tuple[str, str]: + operator, version = self._spec + if operator == "===" or version.endswith(".*"): + return operator, version + + spec_version = self._require_spec_version(version) + canonical_version = canonicalize_version( - self._spec[1], - strip_trailing_zero=(self._spec[0] != "~="), + spec_version, strip_trailing_zero=(operator != "~=") ) - return self._spec[0], canonical_version + + return operator, canonical_version def __hash__(self) -> int: return hash(self._canonical_spec) @@ -381,23 +915,35 @@ class Specifier(BaseSpecifier): # Add the prefix notation to the end of our string prefix += ".*" - return self._get_operator(">=")(prospective, spec) and self._get_operator("==")( - prospective, prefix + return (self._compare_greater_than_equal(prospective, spec)) and ( + self._compare_equal(prospective, prefix) ) + def _get_wildcard_split(self, spec: str) -> tuple[list[str], int]: + """Cached split of a wildcard spec into components and numeric length. + + >>> Specifier("==1.*")._get_wildcard_split("1.*") + (['0', '1'], 2) + >>> Specifier("==3.10.*")._get_wildcard_split("3.10.*") + (['0', '3', '10'], 3) + """ + wildcard_split = self._wildcard_split + if wildcard_split is None: + normalized = canonicalize_version(spec[:-2], strip_trailing_zero=False) + split_spec = _version_split(normalized) + wildcard_split = (split_spec, _numeric_prefix_len(split_spec)) + self._wildcard_split = wildcard_split + return wildcard_split + def _compare_equal(self, prospective: Version, spec: str) -> bool: # We need special logic to handle prefix matching if spec.endswith(".*"): + split_spec, spec_numeric_len = self._get_wildcard_split(spec) + # In the case of prefix matching we want to ignore local segment. normalized_prospective = canonicalize_version( - prospective.public, strip_trailing_zero=False + _public_version(prospective), strip_trailing_zero=False ) - # Get the normalized version string ignoring the trailing .* - normalized_spec = canonicalize_version(spec[:-2], strip_trailing_zero=False) - # Split the spec out by bangs and dots, and pretend that there is - # an implicit dot in between a release segment and a pre-release segment. - split_spec = _version_split(normalized_spec) - # Split the prospective version out by bangs and dots, and pretend # that there is an implicit dot in between a release segment and # a pre-release segment. @@ -405,7 +951,7 @@ class Specifier(BaseSpecifier): # 0-pad the prospective version before shortening it to get the correct # shortened version. - padded_prospective, _ = _pad_version(split_prospective, split_spec) + padded_prospective = _left_pad(split_prospective, spec_numeric_len) # Shorten the prospective version to be the same length as the spec # so that we can determine if the specifier is a prefix of the @@ -415,13 +961,13 @@ class Specifier(BaseSpecifier): return shortened_prospective == split_spec else: # Convert our spec string into a Version - spec_version = Version(spec) + spec_version = self._require_spec_version(spec) # If the specifier does not have a local segment, then we want to # act as if the prospective version also does not have a local # segment. if not spec_version.local: - prospective = Version(prospective.public) + prospective = _public_version(prospective) return prospective == spec_version @@ -432,18 +978,18 @@ class Specifier(BaseSpecifier): # NB: Local version identifiers are NOT permitted in the version # specifier, so local version labels can be universally removed from # the prospective version. - return Version(prospective.public) <= Version(spec) + return _public_version(prospective) <= self._require_spec_version(spec) def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool: # NB: Local version identifiers are NOT permitted in the version # specifier, so local version labels can be universally removed from # the prospective version. - return Version(prospective.public) >= Version(spec) + return _public_version(prospective) >= self._require_spec_version(spec) def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: # Convert our spec to a Version instance, since we'll want to work with # it as a version. - spec = Version(spec_str) + spec = self._require_spec_version(spec_str) # Check to see if the prospective version is less than the spec # version. If it's not we can short circuit and just return False now @@ -451,13 +997,14 @@ class Specifier(BaseSpecifier): if not prospective < spec: return False - # This special case is here so that, unless the specifier itself - # includes is a pre-release version, that we do not accept pre-release - # versions for the version mentioned in the specifier (e.g. <3.1 should - # not match 3.1.dev0, but should match 3.0.dev0). - if not spec.is_prerelease and prospective.is_prerelease: - if Version(prospective.base_version) == Version(spec.base_version): - return False + # The spec says: "= _earliest_prerelease(spec) + ): + return False # If we've gotten to here, it means that prospective version is both # less than the spec version *and* it's not a pre-release of the same @@ -467,7 +1014,7 @@ class Specifier(BaseSpecifier): def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: # Convert our spec to a Version instance, since we'll want to work with # it as a version. - spec = Version(spec_str) + spec = self._require_spec_version(spec_str) # Check to see if the prospective version is greater than the spec # version. If it's not we can short circuit and just return False now @@ -475,26 +1022,28 @@ class Specifier(BaseSpecifier): if not prospective > spec: return False - # This special case is here so that, unless the specifier itself - # includes is a post-release version, that we do not accept - # post-release versions for the version mentioned in the specifier - # (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0). - if not spec.is_postrelease and prospective.is_postrelease: - if Version(prospective.base_version) == Version(spec.base_version): - return False + # The spec says: ">V MUST NOT allow a post-release of the specified + # version unless the specified version is itself a post-release." + if ( + not spec.is_postrelease + and prospective.is_postrelease + and _post_base(prospective) == spec + ): + return False - # Ensure that we do not allow a local version of the version mentioned - # in the specifier, which is technically greater than, to match. - if prospective.local is not None: - if Version(prospective.base_version) == Version(spec.base_version): - return False + # Per the spec: ">V MUST NOT match a local version of the specified + # version". A "local version of V" is any version whose public part + # equals V. So >1.0a1 must not match 1.0a1+local, but must still + # match 1.0a2+local. + if prospective.local is not None and _public_version(prospective) == spec: + return False # If we've gotten to here, it means that prospective version is both # greater than the spec version *and* it's not a pre-release of the # same version in the spec. return True - def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: + def _compare_arbitrary(self, prospective: Version | str, spec: str) -> bool: return str(prospective).lower() == str(spec).lower() def __contains__(self, item: str | Version) -> bool: @@ -512,7 +1061,7 @@ class Specifier(BaseSpecifier): >>> "1.0.0" in Specifier(">=1.2.3") False >>> "1.3.0a1" in Specifier(">=1.2.3") - False + True >>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True) True """ @@ -526,8 +1075,8 @@ class Specifier(BaseSpecifier): :class:`Version` instance. :param prereleases: Whether or not to match prereleases with this Specifier. If set to - ``None`` (the default), it uses :attr:`prereleases` to determine - whether or not prereleases are allowed. + ``None`` (the default), it will follow the recommendation from + :pep:`440` and match prereleases, as there are no other versions. >>> Specifier(">=1.2.3").contains("1.2.3") True @@ -536,35 +1085,37 @@ class Specifier(BaseSpecifier): >>> Specifier(">=1.2.3").contains("1.0.0") False >>> Specifier(">=1.2.3").contains("1.3.0a1") - False - >>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1") True - >>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True) + >>> Specifier(">=1.2.3", prereleases=False).contains("1.3.0a1") + False + >>> Specifier(">=1.2.3").contains("1.3.0a1") True """ - # Determine if prereleases are to be allowed or not. - if prereleases is None: - prereleases = self.prereleases + return bool(list(self.filter([item], prereleases=prereleases))) - # Normalize item to a Version, this allows us to have a shortcut for - # "2.0" in Specifier(">=2") - normalized_item = _coerce_version(item) + @typing.overload + def filter( + self, + iterable: Iterable[UnparsedVersionVar], + prereleases: bool | None = None, + key: None = ..., + ) -> Iterator[UnparsedVersionVar]: ... - # Determine if we should be supporting prereleases in this specifier - # or not, if we do not support prereleases than we can short circuit - # logic if this version is a prereleases. - if normalized_item.is_prerelease and not prereleases: - return False - - # Actually do the comparison to determine if this item is contained - # within this Specifier or not. - operator_callable: CallableOperator = self._get_operator(self.operator) - return operator_callable(normalized_item, self.version) + @typing.overload + def filter( + self, + iterable: Iterable[T], + prereleases: bool | None = None, + key: Callable[[T], UnparsedVersion] = ..., + ) -> Iterator[T]: ... def filter( - self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None - ) -> Iterator[UnparsedVersionVar]: + self, + iterable: Iterable[Any], + prereleases: bool | None = None, + key: Callable[[Any], UnparsedVersion] | None = None, + ) -> Iterator[Any]: """Filter items in the given iterable, that match the specifier. :param iterable: @@ -572,13 +1123,12 @@ class Specifier(BaseSpecifier): The items in the iterable will be filtered according to the specifier. :param prereleases: Whether or not to allow prereleases in the returned iterator. If set to - ``None`` (the default), it will be intelligently decide whether to allow - prereleases or not (based on the :attr:`prereleases` attribute, and - whether the only versions matching are prereleases). - - This method is smarter than just ``filter(Specifier().contains, [...])`` - because it implements the rule from :pep:`440` that a prerelease item - SHOULD be accepted if no other versions match the given specifier. + ``None`` (the default), it will follow the recommendation from :pep:`440` + and match prereleases if there are no other versions. + :param key: + A callable that takes a single argument (an item from the iterable) and + returns a version string or :class:`Version` instance to be used for + filtering. >>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) ['1.3'] @@ -590,41 +1140,101 @@ class Specifier(BaseSpecifier): ['1.3', '1.5a1'] >>> list(Specifier(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) ['1.3', '1.5a1'] + >>> list(Specifier(">=1.2.3").filter( + ... [{"ver": "1.2"}, {"ver": "1.3"}], + ... key=lambda x: x["ver"])) + [{'ver': '1.3'}] """ + prereleases_versions = [] + found_non_prereleases = False - yielded = False - found_prereleases = [] + # Determine if to include prereleases by default + include_prereleases = ( + prereleases if prereleases is not None else self.prereleases + ) - kw = {"prereleases": prereleases if prereleases is not None else True} + # Get the matching operator + operator_callable = self._get_operator(self.operator) - # Attempt to iterate over all the values in the iterable and if any of - # them match, yield them. + # Filter versions for version in iterable: - parsed_version = _coerce_version(version) - - if self.contains(parsed_version, **kw): - # If our version is a prerelease, and we were not set to allow - # prereleases, then we'll store it for later in case nothing - # else matches this specifier. - if parsed_version.is_prerelease and not ( - prereleases or self.prereleases + parsed_version = _coerce_version(version if key is None else key(version)) + match = False + if parsed_version is None: + # === operator can match arbitrary (non-version) strings + if self.operator == "===" and self._compare_arbitrary( + version, self.version ): - found_prereleases.append(version) - # Either this is not a prerelease, or we should have been - # accepting prereleases from the beginning. - else: - yielded = True yield version + elif self.operator == "===": + match = self._compare_arbitrary( + version if key is None else key(version), self.version + ) + else: + match = operator_callable(parsed_version, self.version) - # Now that we've iterated over everything, determine if we've yielded - # any values, and if we have not and we have any prereleases stored up - # then we will go ahead and yield the prereleases. - if not yielded and found_prereleases: - for version in found_prereleases: - yield version + if match and parsed_version is not None: + # If it's not a prerelease or prereleases are allowed, yield it directly + if not parsed_version.is_prerelease or include_prereleases: + found_non_prereleases = True + yield version + # Otherwise collect prereleases for potential later use + elif prereleases is None and self._prereleases is not False: + prereleases_versions.append(version) + + # If no non-prereleases were found and prereleases weren't + # explicitly forbidden, yield the collected prereleases + if ( + not found_non_prereleases + and prereleases is None + and self._prereleases is not False + ): + yield from prereleases_versions -_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") +_prefix_regex = re.compile(r"([0-9]+)((?:a|b|c|rc)[0-9]+)") + + +def _pep440_filter_prereleases( + iterable: Iterable[Any], key: Callable[[Any], UnparsedVersion] | None +) -> Iterator[Any]: + """Filter per PEP 440: exclude prereleases unless no finals exist.""" + # Two lists used: + # * all_nonfinal to preserve order if no finals exist + # * arbitrary_strings for streaming when first final found + all_nonfinal: list[Any] = [] + arbitrary_strings: list[Any] = [] + + found_final = False + for item in iterable: + parsed = _coerce_version(item if key is None else key(item)) + + if parsed is None: + # Arbitrary strings are always included as it is not + # possible to determine if they are prereleases, + # and they have already passed all specifiers. + if found_final: + yield item + else: + arbitrary_strings.append(item) + all_nonfinal.append(item) + continue + + if not parsed.is_prerelease: + # Final release found - flush arbitrary strings, then yield + if not found_final: + yield from arbitrary_strings + found_final = True + yield item + continue + + # Prerelease - buffer if no finals yet, otherwise skip + if not found_final: + all_nonfinal.append(item) + + # No finals found - yield all buffered items + if not found_final: + yield from all_nonfinal def _version_split(version: str) -> list[str]: @@ -641,7 +1251,7 @@ def _version_split(version: str) -> list[str]: result.append(epoch or "0") for item in rest.split("."): - match = _prefix_regex.search(item) + match = _prefix_regex.fullmatch(item) if match: result.extend(match.groups()) else: @@ -666,25 +1276,59 @@ def _is_not_suffix(segment: str) -> bool: ) -def _pad_version(left: list[str], right: list[str]) -> tuple[list[str], list[str]]: - left_split, right_split = [], [] +def _numeric_prefix_len(split: list[str]) -> int: + """Count leading numeric components in a :func:`_version_split` result. - # Get the release segment of our versions - left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left))) - right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right))) + >>> _numeric_prefix_len(["0", "1", "2", "a1"]) + 3 + """ + count = 0 + for segment in split: + if not segment.isdigit(): + break + count += 1 + return count - # Get the rest of our versions - left_split.append(left[len(left_split[0]) :]) - right_split.append(right[len(right_split[0]) :]) - # Insert our padding - left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0]))) - right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0]))) +def _left_pad(split: list[str], target_numeric_len: int) -> list[str]: + """Pad a :func:`_version_split` result with ``"0"`` segments to reach + ``target_numeric_len`` numeric components. Suffix segments are preserved. - return ( - list(itertools.chain.from_iterable(left_split)), - list(itertools.chain.from_iterable(right_split)), - ) + >>> _left_pad(["0", "1", "a1"], 4) + ['0', '1', '0', '0', 'a1'] + """ + numeric_len = _numeric_prefix_len(split) + pad_needed = target_numeric_len - numeric_len + if pad_needed <= 0: + return split + return [*split[:numeric_len], *(["0"] * pad_needed), *split[numeric_len:]] + + +def _operator_cost(op_entry: tuple[CallableOperator, str, str]) -> int: + """Sort key for Cost Based Ordering of specifier operators in _filter_versions. + + Operators run sequentially on a shrinking candidate set, so operators that + reject the most versions should run first to minimize work for later ones. + + Tier 0: Exact equality (==, ===), likely to narrow candidates to one version + Tier 1: Range checks (>=, <=, >, <), cheap and usually reject a large portion + Tier 2: Wildcard equality (==.*) and compatible release (~=), more expensive + Tier 3: Exact !=, cheap but rarely rejects + Tier 4: Wildcard !=.*, expensive and rarely rejects + """ + _, ver, op = op_entry + if op == "==": + return 0 if not ver.endswith(".*") else 2 + if op in (">=", "<=", ">", "<"): + return 1 + if op == "~=": + return 2 + if op == "!=": + return 3 if not ver.endswith(".*") else 4 + if op == "===": + return 0 + + raise ValueError(f"Unknown operator: {op!r}") # pragma: no cover class SpecifierSet(BaseSpecifier): @@ -692,8 +1336,29 @@ class SpecifierSet(BaseSpecifier): It can be passed a single specifier (``>=3.0``), a comma-separated list of specifiers (``>=3.0,!=3.1``), or no specifier at all. + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging + releases. + + .. versionchanged:: 26.2 + + Added a stable pickle format. Pickles created with + packaging 26.2+ can be unpickled with future releases. + Backward compatibility with pickles from + packaging < 26.2 is supported but may be removed in a future + release. """ + __slots__ = ( + "_canonicalized", + "_has_arbitrary", + "_is_unsatisfiable", + "_prereleases", + "_resolved_ops", + "_specs", + ) + def __init__( self, specifiers: str | Iterable[Specifier] = "", @@ -721,17 +1386,33 @@ class SpecifierSet(BaseSpecifier): # strip each item to remove leading/trailing whitespace. split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] - # Make each individual specifier a Specifier and save in a frozen set - # for later. - self._specs = frozenset(map(Specifier, split_specifiers)) + self._specs: tuple[Specifier, ...] = tuple(map(Specifier, split_specifiers)) + # Fast substring check; avoids iterating parsed specs. + self._has_arbitrary = "===" in specifiers else: - # Save the supplied specifiers in a frozen set. - self._specs = frozenset(specifiers) + self._specs = tuple(specifiers) + # Substring check works for both Specifier objects and plain + # strings (setuptools passes lists of strings). + self._has_arbitrary = any("===" in str(s) for s in self._specs) + + self._canonicalized = len(self._specs) <= 1 + self._resolved_ops: list[tuple[CallableOperator, str, str]] | None = None # Store our prereleases value so we can use it later to determine if # we accept prereleases or not. self._prereleases = prereleases + self._is_unsatisfiable: bool | None = None + + def _canonical_specs(self) -> tuple[Specifier, ...]: + """Deduplicate, sort, and cache specs for order-sensitive operations.""" + if not self._canonicalized: + self._specs = tuple(dict.fromkeys(sorted(self._specs, key=str))) + self._canonicalized = True + self._resolved_ops = None + self._is_unsatisfiable = None + return self._specs + @property def prereleases(self) -> bool | None: # If we have been given an explicit prerelease modifier, then we'll @@ -747,11 +1428,78 @@ class SpecifierSet(BaseSpecifier): # Otherwise we'll see if any of the given specifiers accept # prereleases, if any of them do we'll return True, otherwise False. - return any(s.prereleases for s in self._specs) + if any(s.prereleases for s in self._specs): + return True + + return None @prereleases.setter - def prereleases(self, value: bool) -> None: + def prereleases(self, value: bool | None) -> None: self._prereleases = value + self._is_unsatisfiable = None + + def __getstate__(self) -> tuple[tuple[Specifier, ...], bool | None]: + # Return state as a 2-item tuple for compactness: + # (specs, prereleases) + # Cache members are excluded and will be recomputed on demand. + return (self._specs, self._prereleases) + + def __setstate__(self, state: object) -> None: + # Always discard cached values - they will be recomputed on demand. + self._resolved_ops = None + self._is_unsatisfiable = None + + if isinstance(state, tuple): + if len(state) == 2: + # New format (26.2+): (specs, prereleases) + specs, prereleases = state + if ( + isinstance(specs, tuple) + and all(isinstance(s, Specifier) for s in specs) + and _validate_pre(prereleases) + ): + self._specs = specs + self._prereleases = prereleases + self._canonicalized = len(specs) <= 1 + self._has_arbitrary = any("===" in str(s) for s in specs) + return + if len(state) == 2 and isinstance(state[1], dict): + # Format (packaging 26.0-26.1): (None, {slot: value}). + _, slot_dict = state + specs = slot_dict.get("_specs", ()) + prereleases = slot_dict.get("_prereleases") + # Convert frozenset to tuple (26.0 stored as frozenset) + if isinstance(specs, frozenset): + specs = tuple(sorted(specs, key=str)) + if ( + isinstance(specs, tuple) + and all(isinstance(s, Specifier) for s in specs) + and _validate_pre(prereleases) + ): + self._specs = specs + self._prereleases = prereleases + self._canonicalized = len(self._specs) <= 1 + self._has_arbitrary = any("===" in str(s) for s in self._specs) + return + if isinstance(state, dict): + # Old format (packaging <= 25.x, no __slots__): state is a plain dict. + specs = state.get("_specs", ()) + prereleases = state.get("_prereleases") + # Convert frozenset to tuple (26.0 stored as frozenset) + if isinstance(specs, frozenset): + specs = tuple(sorted(specs, key=str)) + if ( + isinstance(specs, tuple) + and all(isinstance(s, Specifier) for s in specs) + and _validate_pre(prereleases) + ): + self._specs = specs + self._prereleases = prereleases + self._canonicalized = len(self._specs) <= 1 + self._has_arbitrary = any("===" in str(s) for s in self._specs) + return + + raise TypeError(f"Cannot restore SpecifierSet from {state!r}") def __repr__(self) -> str: """A representation of the specifier set that shows all internal state. @@ -772,7 +1520,7 @@ class SpecifierSet(BaseSpecifier): else "" ) - return f"" + return f"<{self.__class__.__name__}({str(self)!r}{pre})>" def __str__(self) -> str: """A string representation of the specifier set that can be round-tripped. @@ -785,10 +1533,10 @@ class SpecifierSet(BaseSpecifier): >>> str(SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False)) '!=1.0.1,>=1.0.0' """ - return ",".join(sorted(str(s) for s in self._specs)) + return ",".join(str(s) for s in self._canonical_specs()) def __hash__(self) -> int: - return hash(self._specs) + return hash(self._canonical_specs()) def __and__(self, other: SpecifierSet | str) -> SpecifierSet: """Return a SpecifierSet which is a combination of the two sets. @@ -806,13 +1554,15 @@ class SpecifierSet(BaseSpecifier): return NotImplemented specifier = SpecifierSet() - specifier._specs = frozenset(self._specs | other._specs) + specifier._specs = self._specs + other._specs + specifier._canonicalized = len(specifier._specs) <= 1 + specifier._has_arbitrary = self._has_arbitrary or other._has_arbitrary + specifier._resolved_ops = None - if self._prereleases is None and other._prereleases is not None: + # Combine prerelease settings: use common or non-None value + if self._prereleases is None or self._prereleases == other._prereleases: specifier._prereleases = other._prereleases - elif self._prereleases is not None and other._prereleases is None: - specifier._prereleases = self._prereleases - elif self._prereleases == other._prereleases: + elif other._prereleases is None: specifier._prereleases = self._prereleases else: raise ValueError( @@ -845,7 +1595,7 @@ class SpecifierSet(BaseSpecifier): elif not isinstance(other, SpecifierSet): return NotImplemented - return self._specs == other._specs + return self._canonical_specs() == other._canonical_specs() def __len__(self) -> int: """Returns the number of specifiers in this specifier set.""" @@ -861,6 +1611,113 @@ class SpecifierSet(BaseSpecifier): """ return iter(self._specs) + def _get_ranges(self) -> Sequence[_VersionRange]: + """Intersect all specifiers into a single list of version ranges. + + Returns an empty list when unsatisfiable. ``===`` specs are + modeled as full range; string matching is checked separately + by :meth:`_check_arbitrary_unsatisfiable`. + """ + specs = self._specs + + result: Sequence[_VersionRange] | None = None + for s in specs: + if result is None: + result = s._to_ranges() + else: + result = _intersect_ranges(result, s._to_ranges()) + if not result: + break + + if result is None: # pragma: no cover + raise RuntimeError("_get_ranges called with no specs") + return result + + def is_unsatisfiable(self) -> bool: + """Check whether this specifier set can never be satisfied. + + Returns True if no version can satisfy all specifiers simultaneously. + + >>> SpecifierSet(">=2.0,<1.0").is_unsatisfiable() + True + >>> SpecifierSet(">=1.0,<2.0").is_unsatisfiable() + False + >>> SpecifierSet("").is_unsatisfiable() + False + >>> SpecifierSet("==1.0,!=1.0").is_unsatisfiable() + True + """ + cached = self._is_unsatisfiable + if cached is not None: + return cached + + if not self._specs: + self._is_unsatisfiable = False + return False + + result = not self._get_ranges() + + if not result: + result = self._check_arbitrary_unsatisfiable() + + if not result and self.prereleases is False: + result = self._check_prerelease_only_ranges() + + self._is_unsatisfiable = result + return result + + def _check_prerelease_only_ranges(self) -> bool: + """With prereleases=False, check if every range contains only + pre-release versions (which would be excluded from matching).""" + for lower, upper in self._get_ranges(): + nearest = _nearest_non_prerelease(lower.version) + if nearest is None: + return False + if upper.version is None or nearest < upper.version: + return False + if nearest == upper.version and upper.inclusive: + return False + return True + + def _check_arbitrary_unsatisfiable(self) -> bool: + """Check === (arbitrary equality) specs for unsatisfiability. + + === uses case-insensitive string comparison, so the only candidate + that can match ``===V`` is the literal string V. This method + checks whether that candidate is excluded by other specifiers. + """ + arbitrary = [s for s in self._specs if s.operator == "==="] + if not arbitrary: + return False + + # Multiple === must agree on the same string (case-insensitive). + first = arbitrary[0].version.lower() + if any(s.version.lower() != first for s in arbitrary[1:]): + return True + + # The sole candidate is the === version string. Check whether + # it can satisfy every standard spec. + candidate = _coerce_version(arbitrary[0].version) + + # With prereleases=False, a prerelease candidate is excluded + # by contains() before the === string check even runs. + if ( + self.prereleases is False + and candidate is not None + and candidate.is_prerelease + ): + return True + + standard = [s for s in self._specs if s.operator != "==="] + if not standard: + return False + + if candidate is None: + # Unparsable string cannot satisfy any standard spec. + return True + + return not all(s.contains(candidate) for s in standard) + def __contains__(self, item: UnparsedVersion) -> bool: """Return whether or not the item is contained in this specifier. @@ -876,7 +1733,7 @@ class SpecifierSet(BaseSpecifier): >>> "1.0.1" in SpecifierSet(">=1.0.0,!=1.0.1") False >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1") - False + True >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True) True """ @@ -895,8 +1752,11 @@ class SpecifierSet(BaseSpecifier): :class:`Version` instance. :param prereleases: Whether or not to match prereleases with this SpecifierSet. If set to - ``None`` (the default), it uses :attr:`prereleases` to determine - whether or not prereleases are allowed. + ``None`` (the default), it will follow the recommendation from :pep:`440` + and match prereleases, as there are no other versions. + :param installed: + Whether or not the item is installed. If set to ``True``, it will + accept prerelease versions even if the specifier does not allow them. >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3") True @@ -905,43 +1765,47 @@ class SpecifierSet(BaseSpecifier): >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.0.1") False >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1") - False - >>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1") True + >>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False).contains("1.3.0a1") + False >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True) True """ - # Ensure that our item is a Version instance. - if not isinstance(item, Version): - item = Version(item) + version = _coerce_version(item) - # Determine if we're forcing a prerelease or not, if we're not forcing - # one for this particular filter call, then we'll use whatever the - # SpecifierSet thinks for whether or not we should support prereleases. - if prereleases is None: - prereleases = self.prereleases + if version is not None and installed and version.is_prerelease: + prereleases = True - # We can determine if we're going to allow pre-releases by looking to - # see if any of the underlying items supports them. If none of them do - # and this item is a pre-release then we do not allow it and we can - # short circuit that here. - # Note: This means that 1.0.dev1 would not be contained in something - # like >=1.0.devabc however it would be in >=1.0.debabc,>0.0.dev0 - if not prereleases and item.is_prerelease: - return False + # When item is a string and === is involved, keep it as-is + # so the comparison isn't done against the normalized form. + if version is None or (self._has_arbitrary and not isinstance(item, Version)): + check_item = item + else: + check_item = version + return bool(list(self.filter([check_item], prereleases=prereleases))) - if installed and item.is_prerelease: - item = Version(item.base_version) + @typing.overload + def filter( + self, + iterable: Iterable[UnparsedVersionVar], + prereleases: bool | None = None, + key: None = ..., + ) -> Iterator[UnparsedVersionVar]: ... - # We simply dispatch to the underlying specs here to make sure that the - # given version is contained within all of them. - # Note: This use of all() here means that an empty set of specifiers - # will always return True, this is an explicit design decision. - return all(s.contains(item, prereleases=prereleases) for s in self._specs) + @typing.overload + def filter( + self, + iterable: Iterable[T], + prereleases: bool | None = None, + key: Callable[[T], UnparsedVersion] = ..., + ) -> Iterator[T]: ... def filter( - self, iterable: Iterable[UnparsedVersionVar], prereleases: bool | None = None - ) -> Iterator[UnparsedVersionVar]: + self, + iterable: Iterable[Any], + prereleases: bool | None = None, + key: Callable[[Any], UnparsedVersion] | None = None, + ) -> Iterator[Any]: """Filter items in the given iterable, that match the specifiers in this set. :param iterable: @@ -949,24 +1813,27 @@ class SpecifierSet(BaseSpecifier): The items in the iterable will be filtered according to the specifier. :param prereleases: Whether or not to allow prereleases in the returned iterator. If set to - ``None`` (the default), it will be intelligently decide whether to allow - prereleases or not (based on the :attr:`prereleases` attribute, and - whether the only versions matching are prereleases). - - This method is smarter than just ``filter(SpecifierSet(...).contains, [...])`` - because it implements the rule from :pep:`440` that a prerelease item - SHOULD be accepted if no other versions match the given specifier. + ``None`` (the default), it will follow the recommendation from :pep:`440` + and match prereleases if there are no other versions. + :param key: + A callable that takes a single argument (an item from the iterable) and + returns a version string or :class:`Version` instance to be used for + filtering. >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) ['1.3'] >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", Version("1.4")])) ['1.3', ] >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"])) - [] + ['1.5a1'] >>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) ['1.3', '1.5a1'] >>> list(SpecifierSet(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) ['1.3', '1.5a1'] + >>> list(SpecifierSet(">=1.2.3").filter( + ... [{"ver": "1.2"}, {"ver": "1.3"}], + ... key=lambda x: x["ver"])) + [{'ver': '1.3'}] An "empty" SpecifierSet will filter items based on the presence of prerelease versions in the set. @@ -983,37 +1850,94 @@ class SpecifierSet(BaseSpecifier): # Determine if we're forcing a prerelease or not, if we're not forcing # one for this particular filter call, then we'll use whatever the # SpecifierSet thinks for whether or not we should support prereleases. - if prereleases is None: + if prereleases is None and self.prereleases is not None: prereleases = self.prereleases - # If we have any specifiers, then we want to wrap our iterable in the - # filter method for each one, this will act as a logical AND amongst - # each specifier. + # Filter versions that match all specifiers using Cost Based Ordering. if self._specs: - for spec in self._specs: - iterable = spec.filter(iterable, prereleases=bool(prereleases)) + # When prereleases is None, we need to let all versions through + # the individual filters, then decide about prereleases at the end + # based on whether any non-prereleases matched ALL specs. + + # Fast path: single specifier, delegate directly. + if len(self._specs) == 1: + filtered = self._specs[0].filter( + iterable, + prereleases=True if prereleases is None else prereleases, + key=key, + ) + else: + filtered = self._filter_versions( + iterable, + key, + prereleases=True if prereleases is None else prereleases, + ) + + if prereleases is not None: + return filtered + + return _pep440_filter_prereleases(filtered, key) + + # Handle Empty SpecifierSet. + if prereleases is True: return iter(iterable) - # If we do not have any specifiers, then we need to have a rough filter - # which will filter out any pre-releases, unless there are no final - # releases. - else: - filtered: list[UnparsedVersionVar] = [] - found_prereleases: list[UnparsedVersionVar] = [] - for item in iterable: - parsed_version = _coerce_version(item) + if prereleases is False: + return ( + item + for item in iterable + if ( + (version := _coerce_version(item if key is None else key(item))) + is None + or not version.is_prerelease + ) + ) - # Store any item which is a pre-release for later unless we've - # already found a final version or we are accepting prereleases - if parsed_version.is_prerelease and not prereleases: - if not filtered: - found_prereleases.append(item) - else: - filtered.append(item) + # PEP 440: exclude prereleases unless no final releases matched + return _pep440_filter_prereleases(iterable, key) - # If we've found no items except for pre-releases, then we'll go - # ahead and use the pre-releases - if not filtered and found_prereleases and prereleases is None: - return iter(found_prereleases) + def _filter_versions( + self, + iterable: Iterable[Any], + key: Callable[[Any], UnparsedVersion] | None, + prereleases: bool | None = None, + ) -> Iterator[Any]: + """Filter versions against all specifiers in a single pass. - return iter(filtered) + Uses Cost Based Ordering: specifiers are sorted by _operator_cost so + that cheap range operators reject versions early, avoiding expensive + wildcard or compatible operators on versions that would have been + rejected anyway. + """ + # Pre-resolve operators and sort (cached after first call). + if self._resolved_ops is None: + self._resolved_ops = sorted( + ( + (spec._get_operator(spec.operator), spec.version, spec.operator) + for spec in self._specs + ), + key=_operator_cost, + ) + ops = self._resolved_ops + exclude_prereleases = prereleases is False + + for item in iterable: + parsed = _coerce_version(item if key is None else key(item)) + + if parsed is None: + # Only === can match non-parseable versions. + if all( + op == "===" and str(item).lower() == ver.lower() + for _, ver, op in ops + ): + yield item + elif exclude_prereleases and parsed.is_prerelease: + pass + elif all( + str(item if key is None else key(item)).lower() == ver.lower() + if op == "===" + else op_fn(parsed, ver) + for op_fn, ver, op in ops + ): + # Short-circuits on the first failing operator. + yield item diff --git a/server/libs/packaging/tags.py b/server/libs/packaging/tags.py index 8522f59..9980ab3 100644 --- a/server/libs/packaging/tags.py +++ b/server/libs/packaging/tags.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging +import operator import platform import re import struct @@ -13,19 +14,52 @@ import sys import sysconfig from importlib.machinery import EXTENSION_SUFFIXES from typing import ( + TYPE_CHECKING, Iterable, Iterator, Sequence, Tuple, + TypeVar, cast, ) from . import _manylinux, _musllinux +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + from typing import AbstractSet + + +__all__ = [ + "INTERPRETER_SHORT_NAMES", + "AppleVersion", + "PythonVersion", + "Tag", + "UnsortedTagsError", + "android_platforms", + "compatible_tags", + "cpython_tags", + "create_compatible_tags_selector", + "generic_tags", + "interpreter_name", + "interpreter_version", + "ios_platforms", + "mac_platforms", + "parse_tag", + "platform_tags", + "sys_tags", +] + + +def __dir__() -> list[str]: + return __all__ + + logger = logging.getLogger(__name__) PythonVersion = Sequence[int] AppleVersion = Tuple[int, int] +_T = TypeVar("_T") INTERPRETER_SHORT_NAMES: dict[str, str] = { "python": "py", # Generic. @@ -36,7 +70,19 @@ INTERPRETER_SHORT_NAMES: dict[str, str] = { } -_32_BIT_INTERPRETER = struct.calcsize("P") == 4 +# This function can be unit tested without reloading the module +# (Unlike _32_BIT_INTERPRETER) +def _compute_32_bit_interpreter() -> bool: + return struct.calcsize("P") == 4 + + +_32_BIT_INTERPRETER = _compute_32_bit_interpreter() + + +class UnsortedTagsError(ValueError): + """ + Raised when a tag component is not in sorted order per PEP 425. + """ class Tag: @@ -45,11 +91,29 @@ class Tag: Instances are considered immutable and thus are hashable. Equality checking is also supported. + + Instances are safe to serialize with :mod:`pickle`. They use a stable + format so the same pickle can be loaded in future packaging releases. + + .. versionchanged:: 26.2 + + Added a stable pickle format. Pickles created with packaging 26.2+ can + be unpickled with future releases. Backward compatibility with pickles + from packaging < 26.2 is supported but may be removed in a future + release. """ __slots__ = ["_abi", "_hash", "_interpreter", "_platform"] def __init__(self, interpreter: str, abi: str, platform: str) -> None: + """ + :param str interpreter: The interpreter name, e.g. ``"py"`` + (see :attr:`INTERPRETER_SHORT_NAMES` for mapping + well-known interpreter names to their short names). + :param str abi: The ABI that a wheel supports, e.g. ``"cp37m"``. + :param str platform: The OS/platform the wheel supports, + e.g. ``"win_amd64"``. + """ self._interpreter = interpreter.lower() self._abi = abi.lower() self._platform = platform.lower() @@ -62,14 +126,25 @@ class Tag: @property def interpreter(self) -> str: + """ + The interpreter name, e.g. ``"py"`` (see + :attr:`INTERPRETER_SHORT_NAMES` for mapping well-known interpreter + names to their short names). + """ return self._interpreter @property def abi(self) -> str: + """ + The supported ABI. + """ return self._abi @property def platform(self) -> str: + """ + The OS/platform. + """ return self._platform def __eq__(self, other: object) -> bool: @@ -92,16 +167,69 @@ class Tag: def __repr__(self) -> str: return f"<{self} @ {id(self)}>" + def __getstate__(self) -> tuple[str, str, str]: + # Return state as a 3-item tuple: (interpreter, abi, platform). + # Cache member _hash is excluded and will be recomputed. + return (self._interpreter, self._abi, self._platform) -def parse_tag(tag: str) -> frozenset[Tag]: + def __setstate__(self, state: object) -> None: + if isinstance(state, tuple): + if len(state) == 3 and all(isinstance(s, str) for s in state): + # New format (26.2+): (interpreter, abi, platform) + self._interpreter, self._abi, self._platform = state + self._hash = hash((self._interpreter, self._abi, self._platform)) + return + if len(state) == 2 and isinstance(state[1], dict): + # Old format (packaging <= 26.1, __slots__): (None, {slot: value}). + _, slots = state + try: + interpreter = slots["_interpreter"] + abi = slots["_abi"] + platform = slots["_platform"] + except KeyError: + raise TypeError(f"Cannot restore Tag from {state!r}") from None + if not all( + isinstance(value, str) for value in (interpreter, abi, platform) + ): + raise TypeError(f"Cannot restore Tag from {state!r}") + self._interpreter = interpreter.lower() + self._abi = abi.lower() + self._platform = platform.lower() + self._hash = hash((self._interpreter, self._abi, self._platform)) + return + raise TypeError(f"Cannot restore Tag from {state!r}") + + +def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]: """ - Parses the provided tag (e.g. `py3-none-any`) into a frozenset of Tag instances. + Parses the provided tag (e.g. `py3-none-any`) into a frozenset of + :class:`Tag` instances. Returning a set is required due to the possibility that the tag is a - compressed tag set. + `compressed tag set`_, e.g. ``"py2.py3-none-any"`` which supports both + Python 2 and Python 3. + + If **validate_order** is true, compressed tag set components are checked + to be in sorted order as required by PEP 425. + + :param str tag: The tag to parse, e.g. ``"py3-none-any"``. + :param bool validate_order: Check whether compressed tag set components + are in sorted order. + :raises UnsortedTagsError: If **validate_order** is true and any compressed tag + set component is not in sorted order. + + .. versionadded:: 26.1 + The *validate_order* parameter. """ tags = set() interpreters, abis, platforms = tag.split("-") + if validate_order: + for component in (interpreters, abis, platforms): + parts = component.split(".") + if parts != sorted(parts): + raise UnsortedTagsError( + f"Tag component {component!r} is not in sorted order per PEP 425" + ) for interpreter in interpreters.split("."): for abi in abis.split("."): for platform_ in platforms.split("."): @@ -142,12 +270,25 @@ def _abi3_applies(python_version: PythonVersion, threading: bool) -> bool: """ Determine if the Python version supports abi3. - PEP 384 was first implemented in Python 3.2. The threaded (`--disable-gil`) + PEP 384 was first implemented in Python 3.2. The free-threaded builds do not support abi3. """ return len(python_version) > 1 and tuple(python_version) >= (3, 2) and not threading +def _abi3t_applies(python_version: PythonVersion, threading: bool) -> bool: + """ + Determine if the Python version supports abi3t. + + PEP 803 was first implemented in Python 3.15 but, per PEP 803, this + returns tags going back to Python 3.2 to mirror the abi3 + implementation and leave open the possibility of abi3t wheels + supporting older Python versions. + + """ + return len(python_version) > 1 and tuple(python_version) >= (3, 2) and threading + + def _cpython_abis(py_version: PythonVersion, warn: bool = False) -> list[str]: py_version = tuple(py_version) # To allow for version comparison. abis = [] @@ -189,19 +330,31 @@ def cpython_tags( warn: bool = False, ) -> Iterator[Tag]: """ - Yields the tags for a CPython interpreter. + Yields the tags for the CPython interpreter. - The tags consist of: - - cp-- - - cp-abi3- - - cp-none- - - cp-abi3- # Older Python versions down to 3.2. + The specific tags generated are: - If python_version only specifies a major version then user-provided ABIs and - the 'none' ABItag will be used. + - ``cp--`` + - ``cp--`` + - ``cp-none-`` + - ``cp--`` where "older version" is all older + minor versions down to Python 3.2 (when ``abi3`` was introduced) - If 'abi3' or 'none' are specified in 'abis' then they will be yielded at - their normal position and not at the beginning. + If ``python_version`` only provides a major-only version then only + user-provided ABIs via ``abis`` and the ``none`` ABI will be used. + + The ``stable_abi`` will be either ``abi3`` or ``abi3t`` if `abi` is a + GIL-enabled ABI like `"cp315"` or a free-threaded ABI like `"cp315t"`, + respectively. + + :param Sequence python_version: A one- or two-item sequence representing the + targeted Python version. Defaults to + ``sys.version_info[:2]``. + :param Iterable abis: Iterable of compatible ABIs. Defaults to the ABIs + compatible with the current system. + :param Iterable platforms: Iterable of compatible platforms. Defaults to the + platforms compatible with the current system. + :param bool warn: Whether warnings should be logged. Defaults to ``False``. """ if not python_version: python_version = sys.version_info[:2] @@ -209,16 +362,13 @@ def cpython_tags( interpreter = f"cp{_version_nodot(python_version[:2])}" if abis is None: - if len(python_version) > 1: - abis = _cpython_abis(python_version, warn) - else: - abis = [] + abis = _cpython_abis(python_version, warn) if len(python_version) > 1 else [] abis = list(abis) # 'abi3' and 'none' are explicitly handled later. for explicit_abi in ("abi3", "none"): try: abis.remove(explicit_abi) - except ValueError: + except ValueError: # noqa: PERF203 pass platforms = list(platforms or platform_tags()) @@ -228,16 +378,27 @@ def cpython_tags( threading = _is_threaded_cpython(abis) use_abi3 = _abi3_applies(python_version, threading) - if use_abi3: - yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms) - yield from (Tag(interpreter, "none", platform_) for platform_ in platforms) + use_abi3t = _abi3t_applies(python_version, threading) if use_abi3: + yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms) + if use_abi3t: + yield from (Tag(interpreter, "abi3t", platform_) for platform_ in platforms) + + yield from (Tag(interpreter, "none", platform_) for platform_ in platforms) + + if use_abi3 or use_abi3t: for minor_version in range(python_version[1] - 1, 1, -1): for platform_ in platforms: version = _version_nodot((python_version[0], minor_version)) interpreter = f"cp{version}" - yield Tag(interpreter, "abi3", platform_) + if use_abi3: + yield Tag(interpreter, "abi3", platform_) + if use_abi3t: + # Support for abi3t was introduced in Python 3.15, but in + # principle abi3t wheels are possible for older limited API + # versions, so allow things like ("cp37", "abi3t", "platform") + yield Tag(interpreter, "abi3t", platform_) def _generic_abi() -> list[str]: @@ -289,21 +450,31 @@ def generic_tags( warn: bool = False, ) -> Iterator[Tag]: """ - Yields the tags for a generic interpreter. + Yields the tags for an interpreter which requires no specialization. - The tags consist of: - - -- + This function should be used if one of the other interpreter-specific + functions provided by this module is not appropriate (i.e. not calculating + tags for a CPython interpreter). - The "none" ABI will be added if it was not explicitly provided. + The specific tags generated are: + + - ``--`` + + The ``"none"`` ABI will be added if it was not explicitly provided. + + :param str interpreter: The name of the interpreter. Defaults to being + calculated. + :param Iterable abis: Iterable of compatible ABIs. Defaults to the ABIs + compatible with the current system. + :param Iterable platforms: Iterable of compatible platforms. Defaults to the + platforms compatible with the current system. + :param bool warn: Whether warnings should be logged. Defaults to ``False``. """ if not interpreter: interp_name = interpreter_name() interp_version = interpreter_version(warn=warn) - interpreter = "".join([interp_name, interp_version]) - if abis is None: - abis = _generic_abi() - else: - abis = list(abis) + interpreter = f"{interp_name}{interp_version}" + abis = _generic_abi() if abis is None else list(abis) platforms = list(platforms or platform_tags()) if "none" not in abis: abis.append("none") @@ -333,12 +504,22 @@ def compatible_tags( platforms: Iterable[str] | None = None, ) -> Iterator[Tag]: """ - Yields the sequence of tags that are compatible with a specific version of Python. + Yields the tags for an interpreter compatible with the Python version + specified by ``python_version``. - The tags consist of: - - py*-none- - - -none-any # ... if `interpreter` is provided. - - py*-none-any + The specific tags generated are: + + - ``py*-none-`` + - ``-none-any`` if ``interpreter`` is provided + - ``py*-none-any`` + + :param Sequence python_version: A one- or two-item sequence representing the + compatible version of Python. Defaults to + ``sys.version_info[:2]``. + :param str interpreter: The name of the interpreter (if known), e.g. + ``"cp38"``. Defaults to the current interpreter. + :param Iterable platforms: Iterable of compatible platforms. Defaults to the + platforms compatible with the current system. """ if not python_version: python_version = sys.version_info[:2] @@ -398,12 +579,25 @@ def mac_platforms( version: AppleVersion | None = None, arch: str | None = None ) -> Iterator[str]: """ - Yields the platform tags for a macOS system. + Yields the :attr:`~Tag.platform` tags for macOS. The `version` parameter is a two-item tuple specifying the macOS version to generate platform tags for. The `arch` parameter is the CPU architecture to generate platform tags for. Both parameters default to the appropriate value for the current system. + + :param tuple version: A two-item tuple representing the version of macOS. + Defaults to the current system's version. + :param str arch: The CPU architecture. Defaults to the architecture of the + current system, e.g. ``"x86_64"``. + + .. note:: + Equivalent support for the other major platforms is purposefully not + provided: + + - On Windows, platform compatibility is statically specified + - On Linux, code must be run on the system itself to determine + compatibility """ version_str, _, cpu_arch = platform.mac_ver() if version is None: @@ -424,14 +618,11 @@ def mac_platforms( text=True, ).stdout version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2]))) - else: - version = version + if arch is None: arch = _mac_arch(cpu_arch) - else: - arch = arch - if (10, 0) <= version and version < (11, 0): + if (10, 0) <= version < (11, 0): # Prior to Mac OS 11, each yearly release of Mac OS bumped the # "minor" version number. The major version was always 10. major_version = 10 @@ -477,14 +668,19 @@ def ios_platforms( version: AppleVersion | None = None, multiarch: str | None = None ) -> Iterator[str]: """ - Yields the platform tags for an iOS system. - :param version: A two-item tuple specifying the iOS version to generate - platform tags for. Defaults to the current iOS version. - :param multiarch: The CPU architecture+ABI to generate platform tags for - - (the value used by `sys.implementation._multiarch` e.g., - `arm64_iphoneos` or `x84_64_iphonesimulator`). Defaults to the current - multiarch value. + Yields the :attr:`~Tag.platform` tags for iOS. + + :param tuple version: A two-item tuple representing the version of iOS. + Defaults to the current system's version. + :param str multiarch: The CPU architecture+ABI to be used. This should be in + the format by ``sys.implementation._multiarch`` (e.g., + ``arm64_iphoneos`` or ``x86_64_iphonesimulator``). + Defaults to the current system's multiarch value. + + .. note:: + Behavior of this method is undefined if invoked on non-iOS platforms + without providing explicit version and multiarch arguments. """ if version is None: # if iOS is the current platform, ios_ver *must* be defined. However, @@ -586,13 +782,22 @@ def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]: yield f"linux_{arch}" +def _emscripten_platforms() -> Iterator[str]: + pyemscripten_platform_version = sysconfig.get_config_var( + "PYEMSCRIPTEN_PLATFORM_VERSION" + ) + if pyemscripten_platform_version: + yield f"pyemscripten_{pyemscripten_platform_version}_wasm32" + yield from _generic_platforms() + + def _generic_platforms() -> Iterator[str]: yield _normalize_string(sysconfig.get_platform()) def platform_tags() -> Iterator[str]: """ - Provides the platform tags for this installation. + Yields the :attr:`~Tag.platform` tags for the running interpreter. """ if platform.system() == "Darwin": return mac_platforms() @@ -602,6 +807,8 @@ def platform_tags() -> Iterator[str]: return android_platforms() elif platform.system() == "Linux": return _linux_platforms() + elif platform.system() == "Emscripten": + return _emscripten_platforms() else: return _generic_platforms() @@ -612,6 +819,8 @@ def interpreter_name() -> str: Some implementations have a reserved, two-letter abbreviation which will be returned when appropriate. + + This typically acts as the prefix to the :attr:`~Tag.interpreter` tag. """ name = sys.implementation.name return INTERPRETER_SHORT_NAMES.get(name) or name @@ -619,14 +828,14 @@ def interpreter_name() -> str: def interpreter_version(*, warn: bool = False) -> str: """ - Returns the version of the running interpreter. + Returns the running interpreter's version. + + This typically acts as the suffix to the :attr:`~Tag.interpreter` tag. + + :param bool warn: Whether warnings should be logged. Defaults to ``False``. """ version = _get_config_var("py_version_nodot", warn=warn) - if version: - version = str(version) - else: - version = _version_nodot(sys.version_info[:2]) - return version + return str(version) if version else _version_nodot(sys.version_info[:2]) def _version_nodot(version: PythonVersion) -> str: @@ -635,10 +844,31 @@ def _version_nodot(version: PythonVersion) -> str: def sys_tags(*, warn: bool = False) -> Iterator[Tag]: """ - Returns the sequence of tag triples for the running interpreter. + Yields the sequence of tag triples that the running interpreter supports. - The order of the sequence corresponds to priority order for the - interpreter, from most to least important. + The iterable is ordered so that the best-matching tag is first in the + sequence. The exact preferential order to tags is interpreter-specific, but + in general the tag importance is in the order of: + + 1. Interpreter + 2. Platform + 3. ABI + + This order is due to the fact that an ABI is inherently tied to the + platform, but platform-specific code is not necessarily tied to the ABI. The + interpreter is the most important tag as it dictates basic support for any + wheel. + + The function returns an iterable in order to allow for the possible + short-circuiting of tag generation if the entire sequence is not necessary + and tag calculation happens to be expensive. + + :param bool warn: Whether warnings should be logged. Defaults to ``False``. + + .. versionchanged:: 21.3 + Added the `pp3-none-any` tag (:issue:`311`). + .. versionchanged:: 27.0 + Added the `abi3t` tag (:issue:`1099`). """ interp_name = interpreter_name() @@ -654,3 +884,49 @@ def sys_tags(*, warn: bool = False) -> Iterator[Tag]: else: interp = None yield from compatible_tags(interpreter=interp) + + +def create_compatible_tags_selector( + tags: Iterable[Tag], +) -> Callable[[Iterable[tuple[_T, AbstractSet[Tag]]]], Iterator[_T]]: + """Create a callable to select things compatible with supported tags. + + This function accepts an ordered sequence of tags, with the preferred + tags first. + + The returned callable accepts an iterable of tuples (thing, set[Tag]), + and returns an iterator of things, with the things with the best + matching tags first. + + Example to select compatible wheel filenames: + + >>> from packaging import tags + >>> from packaging.utils import parse_wheel_filename + >>> selector = tags.create_compatible_tags_selector(tags.sys_tags()) + >>> filenames = ["foo-1.0-py3-none-any.whl", "foo-1.0-py2-none-any.whl"] + >>> list(selector([ + ... (filename, parse_wheel_filename(filename)[-1]) for filename in filenames + ... ])) + ['foo-1.0-py3-none-any.whl'] + + .. versionadded:: 26.1 + """ + tag_ranks: dict[Tag, int] = {} + for rank, tag in enumerate(tags): + tag_ranks.setdefault(tag, rank) # ignore duplicate tags, keep first + supported_tags = tag_ranks.keys() + + def selector( + tagged_things: Iterable[tuple[_T, AbstractSet[Tag]]], + ) -> Iterator[_T]: + ranked_things: list[tuple[_T, int]] = [] + for thing, thing_tags in tagged_things: + supported_thing_tags = thing_tags & supported_tags + if supported_thing_tags: + thing_rank = min(tag_ranks[t] for t in supported_thing_tags) + ranked_things.append((thing, thing_rank)) + return iter( + thing for thing, _ in sorted(ranked_things, key=operator.itemgetter(1)) + ) + + return selector diff --git a/server/libs/packaging/utils.py b/server/libs/packaging/utils.py index 2345095..cbd3be2 100644 --- a/server/libs/packaging/utils.py +++ b/server/libs/packaging/utils.py @@ -4,15 +4,36 @@ from __future__ import annotations -import functools import re from typing import NewType, Tuple, Union, cast -from .tags import Tag, parse_tag +from .tags import Tag, UnsortedTagsError, parse_tag from .version import InvalidVersion, Version, _TrimmedRelease +__all__ = [ + "BuildTag", + "InvalidName", + "InvalidSdistFilename", + "InvalidWheelFilename", + "NormalizedName", + "canonicalize_name", + "canonicalize_version", + "is_normalized_name", + "parse_sdist_filename", + "parse_wheel_filename", +] + + +def __dir__() -> list[str]: + return __all__ + + BuildTag = Union[Tuple[()], Tuple[int, str]] + NormalizedName = NewType("NormalizedName", str) +""" +A :class:`typing.NewType` of :class:`str`, representing a normalized name. +""" class InvalidName(ValueError): @@ -35,33 +56,77 @@ class InvalidSdistFilename(ValueError): # Core metadata spec for `Name` _validate_regex = re.compile( - r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE + r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII ) -_canonicalize_regex = re.compile(r"[-_.]+") -_normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$") +_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII) # PEP 427: The build number must start with a digit. -_build_tag_regex = re.compile(r"(\d+)(.*)") +_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII) def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: - if validate and not _validate_regex.match(name): + """ + This function takes a valid Python package or extra name, and returns the + normalized form of it. + + The return type is typed as :class:`NormalizedName`. This allows type + checkers to help require that a string has passed through this function + before use. + + If **validate** is true, then the function will check if **name** is a valid + distribution name before normalizing. + + :param str name: The name to normalize. + :param bool validate: Check whether the name is a valid distribution name. + :raises InvalidName: If **validate** is true and the name is not an + acceptable distribution name. + + >>> from packaging.utils import canonicalize_name + >>> canonicalize_name("Django") + 'django' + >>> canonicalize_name("oslo.concurrency") + 'oslo-concurrency' + >>> canonicalize_name("requests") + 'requests' + """ + if validate and not _validate_regex.fullmatch(name): raise InvalidName(f"name is invalid: {name!r}") - # This is taken from PEP 503. - value = _canonicalize_regex.sub("-", name).lower() - return cast(NormalizedName, value) + # Ensure all ``.`` and ``_`` are ``-`` + # Emulates ``re.sub(r"[-_.]+", "-", name).lower()`` from PEP 503 + # Much faster than re, and even faster than str.translate + value = name.lower().replace("_", "-").replace(".", "-") + # Condense repeats (faster than regex) + while "--" in value: + value = value.replace("--", "-") + return cast("NormalizedName", value) def is_normalized_name(name: str) -> bool: - return _normalized_regex.match(name) is not None + """ + Check if a name is already normalized (i.e. :func:`canonicalize_name` would + roundtrip to the same value). + + :param str name: The name to check. + + >>> from packaging.utils import is_normalized_name + >>> is_normalized_name("requests") + True + >>> is_normalized_name("Django") + False + """ + return _normalized_regex.fullmatch(name) is not None -@functools.singledispatch def canonicalize_version( version: Version | str, *, strip_trailing_zero: bool = True ) -> str: - """ - Return a canonical form of a version as a string. + """Return a canonical form of a version as a string. + This function takes a string representing a package version (or a + :class:`~packaging.version.Version` instance), and returns the + normalized form of it. By default, it strips trailing zeros from + the release segment. + + >>> from packaging.utils import canonicalize_version >>> canonicalize_version('1.0.1') '1.0.1' @@ -77,23 +142,63 @@ def canonicalize_version( >>> canonicalize_version('foo bar baz') 'foo bar baz' + + >>> canonicalize_version('1.4.0.0.0') + '1.4' """ - return str(_TrimmedRelease(str(version)) if strip_trailing_zero else version) - - -@canonicalize_version.register -def _(version: str, *, strip_trailing_zero: bool = True) -> str: - try: - parsed = Version(version) - except InvalidVersion: - # Legacy versions cannot be normalized - return version - return canonicalize_version(parsed, strip_trailing_zero=strip_trailing_zero) + if isinstance(version, str): + try: + version = Version(version) + except InvalidVersion: + return str(version) + return str(_TrimmedRelease(version) if strip_trailing_zero else version) def parse_wheel_filename( filename: str, + *, + validate_order: bool = False, ) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]: + """ + This function takes the filename of a wheel file, and parses it, + returning a tuple of name, version, build number, and tags. + + The name part of the tuple is normalized and typed as + :class:`NormalizedName`. The version portion is an instance of + :class:`~packaging.version.Version`. The build number is ``()`` if + there is no build number in the wheel filename, otherwise a + two-item tuple of an integer for the leading digits and + a string for the rest of the build number. The tags portion is a + frozen set of :class:`~packaging.tags.Tag` instances (as the tag + string format allows multiple tags to be combined into a single + string). + + If **validate_order** is true, compressed tag set components are + checked to be in sorted order as required by PEP 425. + + :param str filename: The name of the wheel file. + :param bool validate_order: Check whether compressed tag set components + are in sorted order. + :raises InvalidWheelFilename: If the filename in question + does not follow the :ref:`wheel specification + `. + + >>> from packaging.utils import parse_wheel_filename + >>> from packaging.tags import Tag + >>> from packaging.version import Version + >>> name, ver, build, tags = parse_wheel_filename("foo-1.0-py3-none-any.whl") + >>> name + 'foo' + >>> ver == Version('1.0') + True + >>> tags == {Tag("py3", "none", "any")} + True + >>> not build + True + + .. versionadded:: 26.1 + The *validate_order* parameter. + """ if not filename.endswith(".whl"): raise InvalidWheelFilename( f"Invalid wheel filename (extension must be '.whl'): {filename!r}" @@ -127,14 +232,42 @@ def parse_wheel_filename( raise InvalidWheelFilename( f"Invalid build number: {build_part} in {filename!r}" ) - build = cast(BuildTag, (int(build_match.group(1)), build_match.group(2))) + build = cast("BuildTag", (int(build_match.group(1)), build_match.group(2))) else: build = () - tags = parse_tag(parts[-1]) + tag_str = parts[-1] + try: + tags = parse_tag(tag_str, validate_order=validate_order) + except UnsortedTagsError: + raise InvalidWheelFilename( + f"Invalid wheel filename (compressed tag set components must be in " + f"sorted order per PEP 425): {filename!r}" + ) from None return (name, version, build, tags) def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]: + """ + This function takes the filename of a sdist file (as specified + in the `Source distribution format`_ documentation), and parses + it, returning a tuple of the normalized name and version as + represented by an instance of :class:`~packaging.version.Version`. + + :param str filename: The name of the sdist file. + :raises InvalidSdistFilename: If the filename does not end + with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not + contain a dash separating the name and the version of the distribution. + + >>> from packaging.utils import parse_sdist_filename + >>> from packaging.version import Version + >>> name, ver = parse_sdist_filename("foo-1.0.tar.gz") + >>> name + 'foo' + >>> ver == Version('1.0') + True + + .. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name + """ if filename.endswith(".tar.gz"): file_stem = filename[: -len(".tar.gz")] elif filename.endswith(".zip"): diff --git a/server/libs/packaging/version.py b/server/libs/packaging/version.py index c9bbda2..97f23fd 100644 --- a/server/libs/packaging/version.py +++ b/server/libs/packaging/version.py @@ -4,49 +4,115 @@ """ .. testsetup:: - from packaging.version import parse, Version + from packaging.version import parse, normalize_pre, Version, _cmpkey """ from __future__ import annotations -import itertools import re -from typing import Any, Callable, NamedTuple, SupportsInt, Tuple, Union +import sys +import typing +from typing import ( + Any, + Callable, + Literal, + NamedTuple, + SupportsInt, + Tuple, + TypedDict, + Union, +) -from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType +if typing.TYPE_CHECKING: + from typing_extensions import Self, Unpack + +if sys.version_info >= (3, 13): # pragma: no cover + from warnings import deprecated as _deprecated +elif typing.TYPE_CHECKING: + from typing_extensions import deprecated as _deprecated +else: # pragma: no cover + import functools + import warnings + + def _deprecated(message: str) -> object: + def decorator(func: Callable[[...], object]) -> object: + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + warnings.warn( + message, + category=DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +_LETTER_NORMALIZATION = { + "alpha": "a", + "beta": "b", + "c": "rc", + "pre": "rc", + "preview": "rc", + "rev": "post", + "r": "post", +} + +__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "normalize_pre", "parse"] + + +def __dir__() -> list[str]: + return __all__ -__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"] LocalType = Tuple[Union[int, str], ...] -CmpPrePostDevType = Union[InfinityType, NegativeInfinityType, Tuple[str, int]] -CmpLocalType = Union[ - NegativeInfinityType, - Tuple[Union[Tuple[int, str], Tuple[NegativeInfinityType, Union[int, str]]], ...], -] -CmpKey = Tuple[ - int, - Tuple[int, ...], - CmpPrePostDevType, - CmpPrePostDevType, - CmpPrePostDevType, - CmpLocalType, +CmpLocalType = Tuple[Tuple[int, str], ...] +CmpSuffix = Tuple[int, int, int, int, int, int] +CmpKey = Union[ + Tuple[int, Tuple[int, ...], CmpSuffix], + Tuple[int, Tuple[int, ...], CmpSuffix, CmpLocalType], ] VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] -class _Version(NamedTuple): - epoch: int - release: tuple[int, ...] - dev: tuple[str, int] | None +class _VersionReplace(TypedDict, total=False): + epoch: int | None + release: tuple[int, ...] | None pre: tuple[str, int] | None - post: tuple[str, int] | None - local: LocalType | None + post: int | None + dev: int | None + local: str | None + + +def normalize_pre(letter: str, /) -> str: + """Normalize the pre-release segment of a version string. + + Returns a lowercase version of the string if not a known pre-release + identifier. + + >>> normalize_pre('alpha') + 'a' + >>> normalize_pre('BETA') + 'b' + >>> normalize_pre('rc') + 'rc' + + :param letter: + + .. versionadded:: 26.1 + """ + letter = letter.lower() + return _LETTER_NORMALIZATION.get(letter, letter) def parse(version: str) -> Version: """Parse the given version string. + This is identical to the :class:`Version` constructor. + >>> parse('1.0.dev1') @@ -67,7 +133,15 @@ class InvalidVersion(ValueError): class _BaseVersion: - _key: tuple[Any, ...] + __slots__ = () + + # This can also be a normal member (see the packaging_legacy package); + # we are just requiring it to be readable. Actually defining a property + # has runtime effect on subclasses, so it's typing only. + if typing.TYPE_CHECKING: + + @property + def _key(self) -> tuple[Any, ...]: ... def __hash__(self) -> int: return hash(self._key) @@ -114,38 +188,56 @@ class _BaseVersion: # Deliberately not anchored to the start and end of the string, to make it # easier for 3rd party code to reuse + +# Note that ++ doesn't behave identically on CPython and PyPy, so not using it here _VERSION_PATTERN = r""" - v? - (?: - (?:(?P[0-9]+)!)? # epoch - (?P[0-9]+(?:\.[0-9]+)*) # release segment + v?+ # optional leading v + (?a: + (?:(?P[0-9]+)!)?+ # epoch + (?P[0-9]+(?:\.[0-9]+)*+) # release segment (?P
                                          # pre-release
-            [-_\.]?
+            [._-]?+
             (?Palpha|a|beta|b|preview|pre|c|rc)
-            [-_\.]?
+            [._-]?+
             (?P[0-9]+)?
-        )?
+        )?+
         (?P                                         # post release
             (?:-(?P[0-9]+))
             |
             (?:
-                [-_\.]?
+                [._-]?
                 (?Ppost|rev|r)
-                [-_\.]?
+                [._-]?
                 (?P[0-9]+)?
             )
-        )?
+        )?+
         (?P                                          # dev release
-            [-_\.]?
+            [._-]?+
             (?Pdev)
-            [-_\.]?
+            [._-]?+
             (?P[0-9]+)?
-        )?
+        )?+
     )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+    (?a:\+
+        (?P                                        # local version
+            [a-z0-9]+
+            (?:[._-][a-z0-9]+)*+
+        )
+    )?+
 """
 
-VERSION_PATTERN = _VERSION_PATTERN
+_VERSION_PATTERN_OLD = _VERSION_PATTERN.replace("*+", "*").replace("?+", "?")
+
+# Possessive qualifiers were added in Python 3.11.
+# CPython 3.11.0-3.11.4 had a bug: https://github.com/python/cpython/pull/107795
+# Older PyPy also had a bug.
+VERSION_PATTERN = (
+    _VERSION_PATTERN_OLD
+    if (sys.implementation.name == "cpython" and sys.version_info < (3, 11, 5))
+    or (sys.implementation.name == "pypy" and sys.version_info < (3, 11, 13))
+    or sys.version_info < (3, 11)
+    else _VERSION_PATTERN
+)
 """
 A string containing the regular expression used to match a valid version.
 
@@ -154,10 +246,93 @@ expressions (for example, matching a version number as part of a file name). The
 regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
 flags set.
 
+.. versionchanged:: 26.0
+
+   The regex now uses possessive qualifiers on Python 3.11 if they are
+   supported (CPython 3.11.5+, PyPy 3.11.13+).
+
 :meta hide-value:
 """
 
 
+# Validation pattern for local version in replace()
+_LOCAL_PATTERN = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", re.IGNORECASE | re.ASCII)
+
+# Fast path: If a version has only digits and dots then we
+# can skip the regex and parse it as a release segment
+_SIMPLE_VERSION_INDICATORS = frozenset(".0123456789")
+
+
+def _validate_epoch(value: object, /) -> int:
+    epoch = value or 0
+    if isinstance(epoch, int) and epoch >= 0:
+        return epoch
+    msg = f"epoch must be non-negative integer, got {epoch}"
+    raise InvalidVersion(msg)
+
+
+def _validate_release(value: object, /) -> tuple[int, ...]:
+    release = (0,) if value is None else value
+    if (
+        isinstance(release, tuple)
+        and len(release) > 0
+        and all(isinstance(i, int) and i >= 0 for i in release)
+    ):
+        return release
+    msg = f"release must be a non-empty tuple of non-negative integers, got {release}"
+    raise InvalidVersion(msg)
+
+
+def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | None:
+    if value is None:
+        return value
+    if isinstance(value, tuple) and len(value) == 2:
+        letter, number = value
+        letter = normalize_pre(letter)
+        if letter in {"a", "b", "rc"} and isinstance(number, int) and number >= 0:
+            # type checkers can't infer the Literal type here on letter
+            return (letter, number)  # type: ignore[return-value]
+    msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
+    raise InvalidVersion(msg)
+
+
+def _validate_post(value: object, /) -> tuple[Literal["post"], int] | None:
+    if value is None:
+        return value
+    if isinstance(value, int) and value >= 0:
+        return ("post", value)
+    msg = f"post must be non-negative integer, got {value}"
+    raise InvalidVersion(msg)
+
+
+def _validate_dev(value: object, /) -> tuple[Literal["dev"], int] | None:
+    if value is None:
+        return value
+    if isinstance(value, int) and value >= 0:
+        return ("dev", value)
+    msg = f"dev must be non-negative integer, got {value}"
+    raise InvalidVersion(msg)
+
+
+def _validate_local(value: object, /) -> LocalType | None:
+    if value is None:
+        return value
+    if isinstance(value, str) and _LOCAL_PATTERN.fullmatch(value):
+        return _parse_local_version(value)
+    msg = f"local must be a valid version string, got {value!r}"
+    raise InvalidVersion(msg)
+
+
+# Backward compatibility for internals before 26.0. Do not use.
+class _Version(NamedTuple):
+    epoch: int
+    release: tuple[int, ...]
+    dev: tuple[Literal["dev"], int] | None
+    pre: tuple[Literal["a", "b", "rc"], int] | None
+    post: tuple[Literal["post"], int] | None
+    local: LocalType | None
+
+
 class Version(_BaseVersion):
     """This class abstracts handling of a project's versions.
 
@@ -180,10 +355,51 @@ class Version(_BaseVersion):
     False
     >>> v1 <= v2
     True
+
+    :class:`Version` is immutable; use :meth:`__replace__` to change
+    part of a version.
+
+    Instances are safe to serialize with :mod:`pickle`. They use a stable
+    format so the same pickle can be loaded in future packaging releases.
+
+    .. versionchanged:: 26.2
+
+        Added a stable pickle format. Pickles created with packaging 26.2+ can
+        be unpickled with future releases.  Backward compatibility with pickles
+        from packaging < 26.2 is supported but may be removed in a future
+        release.
     """
 
-    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
-    _key: CmpKey
+    __slots__ = (
+        "_dev",
+        "_epoch",
+        "_hash_cache",
+        "_key_cache",
+        "_local",
+        "_post",
+        "_pre",
+        "_release",
+    )
+    __match_args__ = ("_str",)
+    """
+    Pattern matching is supported on Python 3.10+.
+
+    .. versionadded:: 26.0
+
+    :meta hide-value:
+    """
+
+    _regex = re.compile(r"\s*" + VERSION_PATTERN + r"\s*", re.VERBOSE | re.IGNORECASE)
+
+    _epoch: int
+    _release: tuple[int, ...]
+    _dev: tuple[Literal["dev"], int] | None
+    _pre: tuple[Literal["a", "b", "rc"], int] | None
+    _post: tuple[Literal["post"], int] | None
+    _local: LocalType | None
+
+    _hash_cache: int | None
+    _key_cache: CmpKey | None
 
     def __init__(self, version: str) -> None:
         """Initialize a Version object.
@@ -195,41 +411,439 @@ class Version(_BaseVersion):
             If the ``version`` does not conform to PEP 440 in any way then this
             exception will be raised.
         """
+        if _SIMPLE_VERSION_INDICATORS.issuperset(version):
+            try:
+                self._release = tuple(map(int, version.split(".")))
+            except ValueError:
+                # Empty parts (from "1..2", ".1", etc.) are invalid versions.
+                # Any other ValueError (e.g. int str-digits limit) should
+                # propagate to the caller.
+                if "" in version.split("."):
+                    raise InvalidVersion(f"Invalid version: {version!r}") from None
+                # TODO: remove "no cover" when Python 3.9 is dropped.
+                raise  # pragma: no cover
+
+            self._epoch = 0
+            self._pre = None
+            self._post = None
+            self._dev = None
+            self._local = None
+            self._key_cache = None
+            self._hash_cache = None
+            return
 
         # Validate the version and parse it into pieces
-        match = self._regex.search(version)
+        match = self._regex.fullmatch(version)
         if not match:
             raise InvalidVersion(f"Invalid version: {version!r}")
+        self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
+        self._release = tuple(map(int, match.group("release").split(".")))
+        # We can type ignore the assignments below because the regex guarantees
+        # the correct strings
+        self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))  # type: ignore[assignment]
+        self._post = _parse_letter_version(  # type: ignore[assignment]
+            match.group("post_l"), match.group("post_n1") or match.group("post_n2")
+        )
+        self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))  # type: ignore[assignment]
+        self._local = _parse_local_version(match.group("local"))
 
-        # Store the parsed out pieces of the version
-        self._version = _Version(
-            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
-            release=tuple(int(i) for i in match.group("release").split(".")),
-            pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
-            post=_parse_letter_version(
-                match.group("post_l"), match.group("post_n1") or match.group("post_n2")
-            ),
-            dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
-            local=_parse_local_version(match.group("local")),
+        # Key which will be used for sorting
+        self._key_cache = None
+        self._hash_cache = None
+
+    @classmethod
+    def from_parts(
+        cls,
+        *,
+        epoch: int = 0,
+        release: tuple[int, ...],
+        pre: tuple[str, int] | None = None,
+        post: int | None = None,
+        dev: int | None = None,
+        local: str | None = None,
+    ) -> Self:
+        """
+        Return a new version composed of the various parts.
+
+        This allows you to build a version without going though a string and
+        running a regular expression. It normalizes pre-release strings. The
+        ``release=`` keyword argument is required.
+
+        >>> Version.from_parts(release=(1,2,3))
+        
+        >>> Version.from_parts(release=(0,1,0), pre=("b", 1))
+        
+
+        :param epoch:
+        :param release: This version tuple is required
+
+        .. versionadded:: 26.1
+        """
+        _epoch = _validate_epoch(epoch)
+        _release = _validate_release(release)
+        _pre = _validate_pre(pre) if pre is not None else None
+        _post = _validate_post(post) if post is not None else None
+        _dev = _validate_dev(dev) if dev is not None else None
+        _local = _validate_local(local) if local is not None else None
+
+        new_version = cls.__new__(cls)
+        new_version._key_cache = None
+        new_version._hash_cache = None
+        new_version._epoch = _epoch
+        new_version._release = _release
+        new_version._pre = _pre
+        new_version._post = _post
+        new_version._dev = _dev
+        new_version._local = _local
+
+        return new_version
+
+    def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self:
+        """
+        __replace__(*, epoch=..., release=..., pre=..., post=..., dev=..., local=...)
+
+        Return a new version with parts replaced.
+
+        This returns a new version (unless no parts were changed). The
+        pre-release is normalized. Setting a value to ``None`` clears it.
+
+        >>> v = Version("1.2.3")
+        >>> v.__replace__(pre=("a", 1))
+        
+
+        :param int | None epoch:
+        :param tuple[int, ...] | None release:
+        :param tuple[str, int] | None pre:
+        :param int | None post:
+        :param int | None dev:
+        :param str | None local:
+
+        .. versionadded:: 26.0
+        .. versionchanged:: 26.1
+
+           The pre-release portion is now normalized.
+        """
+        epoch = _validate_epoch(kwargs["epoch"]) if "epoch" in kwargs else self._epoch
+        release = (
+            _validate_release(kwargs["release"])
+            if "release" in kwargs
+            else self._release
+        )
+        pre = _validate_pre(kwargs["pre"]) if "pre" in kwargs else self._pre
+        post = _validate_post(kwargs["post"]) if "post" in kwargs else self._post
+        dev = _validate_dev(kwargs["dev"]) if "dev" in kwargs else self._dev
+        local = _validate_local(kwargs["local"]) if "local" in kwargs else self._local
+
+        if (
+            epoch == self._epoch
+            and release == self._release
+            and pre == self._pre
+            and post == self._post
+            and dev == self._dev
+            and local == self._local
+        ):
+            return self
+
+        new_version = self.__class__.__new__(self.__class__)
+        new_version._key_cache = None
+        new_version._hash_cache = None
+        new_version._epoch = epoch
+        new_version._release = release
+        new_version._pre = pre
+        new_version._post = post
+        new_version._dev = dev
+        new_version._local = local
+
+        return new_version
+
+    @property
+    def _key(self) -> CmpKey:
+        if self._key_cache is None:
+            self._key_cache = _cmpkey(
+                self._epoch,
+                self._release,
+                self._pre,
+                self._post,
+                self._dev,
+                self._local,
+            )
+        return self._key_cache
+
+    # __hash__ must be defined when __eq__ is overridden,
+    # otherwise Python sets __hash__ to None.
+    def __hash__(self) -> int:
+        if (cached_hash := self._hash_cache) is not None:
+            return cached_hash
+
+        if (key := self._key_cache) is None:
+            self._key_cache = key = _cmpkey(
+                self._epoch,
+                self._release,
+                self._pre,
+                self._post,
+                self._dev,
+                self._local,
+            )
+        self._hash_cache = cached_hash = hash(key)
+        return cached_hash
+
+    # Override comparison methods to use direct _key_cache access
+    # This is faster than property access, especially before Python 3.12
+    def __lt__(self, other: _BaseVersion) -> bool:
+        if isinstance(other, Version):
+            if self._key_cache is None:
+                self._key_cache = _cmpkey(
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                )
+            if other._key_cache is None:
+                other._key_cache = _cmpkey(
+                    other._epoch,
+                    other._release,
+                    other._pre,
+                    other._post,
+                    other._dev,
+                    other._local,
+                )
+            return self._key_cache < other._key_cache
+
+        if not isinstance(other, _BaseVersion):
+            return NotImplemented
+
+        return super().__lt__(other)
+
+    def __le__(self, other: _BaseVersion) -> bool:
+        if isinstance(other, Version):
+            if self._key_cache is None:
+                self._key_cache = _cmpkey(
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                )
+            if other._key_cache is None:
+                other._key_cache = _cmpkey(
+                    other._epoch,
+                    other._release,
+                    other._pre,
+                    other._post,
+                    other._dev,
+                    other._local,
+                )
+            return self._key_cache <= other._key_cache
+
+        if not isinstance(other, _BaseVersion):
+            return NotImplemented
+
+        return super().__le__(other)
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, Version):
+            if self._key_cache is None:
+                self._key_cache = _cmpkey(
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                )
+            if other._key_cache is None:
+                other._key_cache = _cmpkey(
+                    other._epoch,
+                    other._release,
+                    other._pre,
+                    other._post,
+                    other._dev,
+                    other._local,
+                )
+            return self._key_cache == other._key_cache
+
+        if not isinstance(other, _BaseVersion):
+            return NotImplemented
+
+        return super().__eq__(other)
+
+    def __ge__(self, other: _BaseVersion) -> bool:
+        if isinstance(other, Version):
+            if self._key_cache is None:
+                self._key_cache = _cmpkey(
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                )
+            if other._key_cache is None:
+                other._key_cache = _cmpkey(
+                    other._epoch,
+                    other._release,
+                    other._pre,
+                    other._post,
+                    other._dev,
+                    other._local,
+                )
+            return self._key_cache >= other._key_cache
+
+        if not isinstance(other, _BaseVersion):
+            return NotImplemented
+
+        return super().__ge__(other)
+
+    def __gt__(self, other: _BaseVersion) -> bool:
+        if isinstance(other, Version):
+            if self._key_cache is None:
+                self._key_cache = _cmpkey(
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                )
+            if other._key_cache is None:
+                other._key_cache = _cmpkey(
+                    other._epoch,
+                    other._release,
+                    other._pre,
+                    other._post,
+                    other._dev,
+                    other._local,
+                )
+            return self._key_cache > other._key_cache
+
+        if not isinstance(other, _BaseVersion):
+            return NotImplemented
+
+        return super().__gt__(other)
+
+    def __ne__(self, other: object) -> bool:
+        if isinstance(other, Version):
+            if self._key_cache is None:
+                self._key_cache = _cmpkey(
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                )
+            if other._key_cache is None:
+                other._key_cache = _cmpkey(
+                    other._epoch,
+                    other._release,
+                    other._pre,
+                    other._post,
+                    other._dev,
+                    other._local,
+                )
+            return self._key_cache != other._key_cache
+
+        if not isinstance(other, _BaseVersion):
+            return NotImplemented
+
+        return super().__ne__(other)
+
+    def __getstate__(
+        self,
+    ) -> tuple[
+        int,
+        tuple[int, ...],
+        tuple[str, int] | None,
+        tuple[str, int] | None,
+        tuple[str, int] | None,
+        LocalType | None,
+    ]:
+        # Return state as a 6-item tuple for compactness:
+        #   (epoch, release, pre, post, dev, local)
+        # Cache members are excluded and will be recomputed on demand
+        return (
+            self._epoch,
+            self._release,
+            self._pre,
+            self._post,
+            self._dev,
+            self._local,
         )
 
-        # Generate a key which will be used for sorting
-        self._key = _cmpkey(
-            self._version.epoch,
-            self._version.release,
-            self._version.pre,
-            self._version.post,
-            self._version.dev,
-            self._version.local,
+    def __setstate__(self, state: object) -> None:
+        # Always discard cached values — they may contain stale references
+        # (e.g. packaging._structures.InfinityType from pre-26.1 pickles)
+        # and will be recomputed on demand from the core fields above.
+        self._key_cache = None
+        self._hash_cache = None
+
+        if isinstance(state, tuple):
+            if len(state) == 6:
+                # New format (26.2+): (epoch, release, pre, post, dev, local)
+                (
+                    self._epoch,
+                    self._release,
+                    self._pre,
+                    self._post,
+                    self._dev,
+                    self._local,
+                ) = state
+                return
+            if len(state) == 2:
+                # Format (packaging 26.0-26.1): (None, {slot: value}).
+                _, slot_dict = state
+                if isinstance(slot_dict, dict):
+                    self._epoch = slot_dict["_epoch"]
+                    self._release = slot_dict["_release"]
+                    self._pre = slot_dict.get("_pre")
+                    self._post = slot_dict.get("_post")
+                    self._dev = slot_dict.get("_dev")
+                    self._local = slot_dict.get("_local")
+                    return
+        if isinstance(state, dict):
+            # Old format (packaging <= 25.x, no __slots__): state is a plain
+            # dict with "_version" (_Version NamedTuple) and "_key" entries.
+            version_nt = state.get("_version")
+            if version_nt is not None:
+                self._epoch = version_nt.epoch
+                self._release = version_nt.release
+                self._pre = version_nt.pre
+                self._post = version_nt.post
+                self._dev = version_nt.dev
+                self._local = version_nt.local
+                return
+
+        raise TypeError(f"Cannot restore Version from {state!r}")
+
+    @property
+    @_deprecated("Version._version is private and will be removed soon")
+    def _version(self) -> _Version:
+        return _Version(
+            self._epoch, self._release, self._dev, self._pre, self._post, self._local
         )
 
+    @_version.setter
+    @_deprecated("Version._version is private and will be removed soon")
+    def _version(self, value: _Version) -> None:
+        self._epoch = value.epoch
+        self._release = value.release
+        self._dev = value.dev
+        self._pre = value.pre
+        self._post = value.post
+        self._local = value.local
+        self._key_cache = None
+        self._hash_cache = None
+
     def __repr__(self) -> str:
         """A representation of the Version that shows all internal state.
 
         >>> Version('1.0.0')
         
         """
-        return f""
+        return f"<{self.__class__.__name__}({str(self)!r})>"
 
     def __str__(self) -> str:
         """A string representation of the version that can be round-tripped.
@@ -237,32 +851,35 @@ class Version(_BaseVersion):
         >>> str(Version("1.0a5"))
         '1.0a5'
         """
-        parts = []
+        # This is a hot function, so not calling self.base_version
+        version = ".".join(map(str, self.release))
 
         # Epoch
-        if self.epoch != 0:
-            parts.append(f"{self.epoch}!")
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self.release))
+        if self.epoch:
+            version = f"{self.epoch}!{version}"
 
         # Pre-release
         if self.pre is not None:
-            parts.append("".join(str(x) for x in self.pre))
+            version += "".join(map(str, self.pre))
 
         # Post-release
         if self.post is not None:
-            parts.append(f".post{self.post}")
+            version += f".post{self.post}"
 
         # Development release
         if self.dev is not None:
-            parts.append(f".dev{self.dev}")
+            version += f".dev{self.dev}"
 
         # Local version segment
         if self.local is not None:
-            parts.append(f"+{self.local}")
+            version += f"+{self.local}"
 
-        return "".join(parts)
+        return version
+
+    @property
+    def _str(self) -> str:
+        """Internal property for match_args"""
+        return str(self)
 
     @property
     def epoch(self) -> int:
@@ -273,7 +890,7 @@ class Version(_BaseVersion):
         >>> Version("1!2.0.0").epoch
         1
         """
-        return self._version.epoch
+        return self._epoch
 
     @property
     def release(self) -> tuple[int, ...]:
@@ -289,10 +906,10 @@ class Version(_BaseVersion):
         Includes trailing zeroes but not the epoch or any pre-release / development /
         post-release suffixes.
         """
-        return self._version.release
+        return self._release
 
     @property
-    def pre(self) -> tuple[str, int] | None:
+    def pre(self) -> tuple[Literal["a", "b", "rc"], int] | None:
         """The pre-release segment of the version.
 
         >>> print(Version("1.2.3").pre)
@@ -304,7 +921,7 @@ class Version(_BaseVersion):
         >>> Version("1.2.3rc1").pre
         ('rc', 1)
         """
-        return self._version.pre
+        return self._pre
 
     @property
     def post(self) -> int | None:
@@ -315,7 +932,7 @@ class Version(_BaseVersion):
         >>> Version("1.2.3.post1").post
         1
         """
-        return self._version.post[1] if self._version.post else None
+        return self._post[1] if self._post else None
 
     @property
     def dev(self) -> int | None:
@@ -326,7 +943,7 @@ class Version(_BaseVersion):
         >>> Version("1.2.3.dev1").dev
         1
         """
-        return self._version.dev[1] if self._version.dev else None
+        return self._dev[1] if self._dev else None
 
     @property
     def local(self) -> str | None:
@@ -337,8 +954,8 @@ class Version(_BaseVersion):
         >>> Version("1.2.3+abc").local
         'abc'
         """
-        if self._version.local:
-            return ".".join(str(x) for x in self._version.local)
+        if self._local:
+            return ".".join(str(x) for x in self._local)
         else:
             return None
 
@@ -346,6 +963,9 @@ class Version(_BaseVersion):
     def public(self) -> str:
         """The public portion of the version.
 
+        This returns a string. If you want a :class:`Version` again and care
+        about performance, use ``v.__replace__(local=None)`` instead.
+
         >>> Version("1.2.3").public
         '1.2.3'
         >>> Version("1.2.3+abc").public
@@ -359,6 +979,10 @@ class Version(_BaseVersion):
     def base_version(self) -> str:
         """The "base version" of the version.
 
+        This returns a string. If you want a :class:`Version` again and care
+        about performance, use
+        ``v.__replace__(pre=None, post=None, dev=None, local=None)`` instead.
+
         >>> Version("1.2.3").base_version
         '1.2.3'
         >>> Version("1.2.3+abc").base_version
@@ -369,16 +993,8 @@ class Version(_BaseVersion):
         The "base version" is the public version of the project without any pre or post
         release markers.
         """
-        parts = []
-
-        # Epoch
-        if self.epoch != 0:
-            parts.append(f"{self.epoch}!")
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self.release))
-
-        return "".join(parts)
+        release_segment = ".".join(map(str, self.release))
+        return f"{self.epoch}!{release_segment}" if self.epoch else release_segment
 
     @property
     def is_prerelease(self) -> bool:
@@ -452,6 +1068,20 @@ class Version(_BaseVersion):
 
 
 class _TrimmedRelease(Version):
+    __slots__ = ()
+
+    def __init__(self, version: str | Version) -> None:
+        if isinstance(version, Version):
+            self._epoch = version._epoch
+            self._release = version._release
+            self._dev = version._dev
+            self._pre = version._pre
+            self._post = version._post
+            self._local = version._local
+            self._key_cache = version._key_cache
+            return
+        super().__init__(version)  # pragma: no cover
+
     @property
     def release(self) -> tuple[int, ...]:
         """
@@ -462,45 +1092,35 @@ class _TrimmedRelease(Version):
         >>> _TrimmedRelease('0.0').release
         (0,)
         """
+        # This leaves one 0.
         rel = super().release
-        nonzeros = (index for index, val in enumerate(rel) if val)
-        last_nonzero = max(nonzeros, default=0)
-        return rel[: last_nonzero + 1]
+        len_release = len(rel)
+        i = len_release
+        while i > 1 and rel[i - 1] == 0:
+            i -= 1
+        return rel if i == len_release else rel[:i]
 
 
 def _parse_letter_version(
     letter: str | None, number: str | bytes | SupportsInt | None
 ) -> tuple[str, int] | None:
     if letter:
-        # We consider there to be an implicit 0 in a pre-release if there is
-        # not a numeral associated with it.
-        if number is None:
-            number = 0
-
         # We normalize any letters to their lower case form
         letter = letter.lower()
 
         # We consider some words to be alternate spellings of other words and
         # in those cases we want to normalize the spellings to our preferred
         # spelling.
-        if letter == "alpha":
-            letter = "a"
-        elif letter == "beta":
-            letter = "b"
-        elif letter in ["c", "pre", "preview"]:
-            letter = "rc"
-        elif letter in ["rev", "r"]:
-            letter = "post"
+        letter = _LETTER_NORMALIZATION.get(letter, letter)
 
-        return letter, int(number)
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        return letter, int(number or 0)
 
-    assert not letter
     if number:
         # We assume if we are given a number, but we are not given a letter
         # then this is using the implicit post release syntax (e.g. 1.0-1)
-        letter = "post"
-
-        return letter, int(number)
+        return "post", int(number)
 
     return None
 
@@ -510,7 +1130,8 @@ _local_version_separators = re.compile(r"[\._-]")
 
 def _parse_local_version(local: str | None) -> LocalType | None:
     """
-    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
+    Takes a string like ``"abc.1.twelve"`` and turns it into
+    ``("abc", 1, "twelve")``.
     """
     if local is not None:
         return tuple(
@@ -520,6 +1141,19 @@ def _parse_local_version(local: str | None) -> LocalType | None:
     return None
 
 
+# Sort ranks for pre-release: dev-only < a < b < rc < stable (no pre-release).
+_PRE_RANK = {"a": 0, "b": 1, "rc": 2}
+_PRE_RANK_DEV_ONLY = -1  # sorts before a(0)
+_PRE_RANK_STABLE = 3  # sorts after rc(2)
+
+# In local version segments, strings sort before ints per PEP 440.
+_LOCAL_STR_RANK = -1  # sorts before all non-negative ints
+
+# Pre-computed suffix for stable releases (no pre, post, or dev segments).
+# See _cmpkey() for the suffix layout.
+_STABLE_SUFFIX = (_PRE_RANK_STABLE, 0, 0, 0, 1, 0)
+
+
 def _cmpkey(
     epoch: int,
     release: tuple[int, ...],
@@ -528,55 +1162,70 @@ def _cmpkey(
     dev: tuple[str, int] | None,
     local: LocalType | None,
 ) -> CmpKey:
-    # When we compare a release version, we want to compare it with all of the
-    # trailing zeros removed. So we'll use a reverse the list, drop all the now
-    # leading zeros until we come to something non zero, then take the rest
-    # re-reverse it back into the correct order and make it a tuple and use
-    # that for our sorting key.
-    _release = tuple(
-        reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
-    )
+    """Build a comparison key for PEP 440 ordering.
+
+    Returns ``(epoch, release, suffix)`` or
+    ``(epoch, release, suffix, local)`` so that plain tuple
+    comparison gives the correct order.
+
+    Trailing zeros are stripped from the release so that ``1.0.0 == 1``.
+
+    The suffix is a flat 6-int tuple that encodes pre/post/dev:
+    ``(pre_rank, pre_n, post_rank, post_n, dev_rank, dev_n)``
+
+    pre_rank: dev-only=-1, a=0, b=1, rc=2, no-pre=3
+        Dev-only releases (no pre or post) get -1 so they sort before
+        any alpha/beta/rc.  Releases without a pre-release tag get 3
+        so they sort after rc.
+    post_rank: no-post=0, post=1
+        Releases without a post segment sort before those with one.
+    dev_rank: dev=0, no-dev=1
+        Releases without a dev segment sort after those with one.
+
+    Local segments use ``(n, "")`` for ints and ``(-1, s)`` for strings,
+    following PEP 440: strings sort before ints, strings compare
+    lexicographically, ints compare numerically, and shorter segments
+    sort before longer when prefixes match.  Versions without a local
+    segment sort before those with one (3-tuple < 4-tuple).
+
+    >>> _cmpkey(0, (1, 0, 0), None, None, None, None)
+    (0, (1,), (3, 0, 0, 0, 1, 0))
+    >>> _cmpkey(0, (1,), ("a", 1), None, None, None)
+    (0, (1,), (0, 1, 0, 0, 1, 0))
+    >>> _cmpkey(0, (1,), None, None, None, ("ubuntu", 1))
+    (0, (1,), (3, 0, 0, 0, 1, 0), ((-1, 'ubuntu'), (1, '')))
+    """
+    # Strip trailing zeros: 1.0.0 compares equal to 1.
+    len_release = len(release)
+    i = len_release
+    while i and release[i - 1] == 0:
+        i -= 1
+    trimmed = release if i == len_release else release[:i]
+
+    # Fast path: stable release with no local segment.
+    if pre is None and post is None and dev is None and local is None:
+        return epoch, trimmed, _STABLE_SUFFIX
 
-    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
-    # We'll do this by abusing the pre segment, but we _only_ want to do this
-    # if there is not a pre or a post segment. If we have one of those then
-    # the normal sorting rules will handle this case correctly.
     if pre is None and post is None and dev is not None:
-        _pre: CmpPrePostDevType = NegativeInfinity
-    # Versions without a pre-release (except as noted above) should sort after
-    # those with one.
+        # dev-only (e.g. 1.0.dev1) sorts before all pre-releases.
+        pre_rank, pre_n = _PRE_RANK_DEV_ONLY, 0
     elif pre is None:
-        _pre = Infinity
+        pre_rank, pre_n = _PRE_RANK_STABLE, 0
     else:
-        _pre = pre
+        pre_rank, pre_n = _PRE_RANK[pre[0]], pre[1]
 
-    # Versions without a post segment should sort before those with one.
-    if post is None:
-        _post: CmpPrePostDevType = NegativeInfinity
+    post_rank = 0 if post is None else 1
+    post_n = 0 if post is None else post[1]
 
-    else:
-        _post = post
+    dev_rank = 1 if dev is None else 0
+    dev_n = 0 if dev is None else dev[1]
 
-    # Versions without a development segment should sort after those with one.
-    if dev is None:
-        _dev: CmpPrePostDevType = Infinity
-
-    else:
-        _dev = dev
+    suffix = (pre_rank, pre_n, post_rank, post_n, dev_rank, dev_n)
 
     if local is None:
-        # Versions without a local segment should sort before those with one.
-        _local: CmpLocalType = NegativeInfinity
-    else:
-        # Versions with a local segment need that segment parsed to implement
-        # the sorting rules in PEP440.
-        # - Alpha numeric segments sort before numeric segments
-        # - Alpha numeric segments sort lexicographically
-        # - Numeric segments sort numerically
-        # - Shorter versions sort before longer versions when the prefixes
-        #   match exactly
-        _local = tuple(
-            (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
-        )
+        return epoch, trimmed, suffix
 
-    return epoch, _release, _pre, _post, _dev, _local
+    cmp_local: CmpLocalType = tuple(
+        (seg, "") if isinstance(seg, int) else (_LOCAL_STR_RANK, seg) for seg in local
+    )
+    return epoch, trimmed, suffix, cmp_local
diff --git a/server/libs/pathspec-0.11.2.dist-info/INSTALLER b/server/libs/pathspec-0.11.2.dist-info/INSTALLER
index a1b589e..5c69047 100644
--- a/server/libs/pathspec-0.11.2.dist-info/INSTALLER
+++ b/server/libs/pathspec-0.11.2.dist-info/INSTALLER
@@ -1 +1 @@
-pip
+uv
\ No newline at end of file
diff --git a/server/libs/pathspec-0.11.2.dist-info/RECORD b/server/libs/pathspec-0.11.2.dist-info/RECORD
index 65483bf..0c2cb6f 100644
--- a/server/libs/pathspec-0.11.2.dist-info/RECORD
+++ b/server/libs/pathspec-0.11.2.dist-info/RECORD
@@ -1,23 +1,15 @@
-pathspec-0.11.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+pathspec-0.11.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
 pathspec-0.11.2.dist-info/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
 pathspec-0.11.2.dist-info/METADATA,sha256=SxnZo-5WRH5npmxwSmYWT1DThQTSIfanQM8_-j8ye1g,19563
 pathspec-0.11.2.dist-info/RECORD,,
 pathspec-0.11.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
 pathspec-0.11.2.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
 pathspec/__init__.py,sha256=7SXysmS-FbGnfonqXtaSm6aUKdepQCXdvd4ArWAMJak,1630
-pathspec/__pycache__/__init__.cpython-311.pyc,,
-pathspec/__pycache__/_meta.cpython-311.pyc,,
-pathspec/__pycache__/gitignore.cpython-311.pyc,,
-pathspec/__pycache__/pathspec.cpython-311.pyc,,
-pathspec/__pycache__/pattern.cpython-311.pyc,,
-pathspec/__pycache__/util.cpython-311.pyc,,
 pathspec/_meta.py,sha256=KkXyQhYw9KfMMlZeEuw6TV5Ar7qn_Y9yF4jTBeiJ-pQ,2223
 pathspec/gitignore.py,sha256=nHZA92AltTIfCLf1i4uwvvXeEdfvbNBjetogn0ueGJM,3895
 pathspec/pathspec.py,sha256=O8oFAbo71uvwFWZZm7c2iFy0nzClyEcxOFJHg_EL8WQ,9530
 pathspec/pattern.py,sha256=HVpwUuGMAW7WOtgPOkZUmJY-lg84BtWVvkVXcc_ha28,5784
 pathspec/patterns/__init__.py,sha256=vAzIEqBc2KsvWsiszsLCeYQwQVWXIHzbHNgq5TNrPdk,302
-pathspec/patterns/__pycache__/__init__.cpython-311.pyc,,
-pathspec/patterns/__pycache__/gitwildmatch.cpython-311.pyc,,
 pathspec/patterns/gitwildmatch.py,sha256=7f8zEBMvySzznrm7oo_geFV0NnmxUucZaYQxtsnMBQ8,12438
 pathspec/py.typed,sha256=wq7wwDeyBungK6DsiV4O-IujgKzARwHz94uQshdpdEU,68
 pathspec/util.py,sha256=8w65a_vDtw3eCyIY5LVQ8EgUGekLwdIBne5yORdIoOQ,20273
diff --git a/server/libs/ply-3.11.dist-info/INSTALLER b/server/libs/ply-3.11.dist-info/INSTALLER
index a1b589e..5c69047 100644
--- a/server/libs/ply-3.11.dist-info/INSTALLER
+++ b/server/libs/ply-3.11.dist-info/INSTALLER
@@ -1 +1 @@
-pip
+uv
\ No newline at end of file
diff --git a/server/libs/ply-3.11.dist-info/RECORD b/server/libs/ply-3.11.dist-info/RECORD
index 5a76af5..109b118 100644
--- a/server/libs/ply-3.11.dist-info/RECORD
+++ b/server/libs/ply-3.11.dist-info/RECORD
@@ -1,5 +1,5 @@
 ply-3.11.dist-info/DESCRIPTION.rst,sha256=nnBY1Nj_GhIsOFck7R2yGHobQVosxi2CPQkHgeSZ0Hg,519
-ply-3.11.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+ply-3.11.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
 ply-3.11.dist-info/METADATA,sha256=pYZ9p1TsWGQ8Kxp9yEJVyvs25PkR5h3gIDuTOCsvJGg,844
 ply-3.11.dist-info/RECORD,,
 ply-3.11.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -7,12 +7,6 @@ ply-3.11.dist-info/WHEEL,sha256=kdsN-5OJAZIiHN-iO4Rhl82KyS0bDWf4uBwMbkNafr8,110
 ply-3.11.dist-info/metadata.json,sha256=s7M7va9E25_7TRpzHfNCfN73Ieiy5iKogF0PzXtMxMI,515
 ply-3.11.dist-info/top_level.txt,sha256=gDYBHRQ7Vy0tY0AjXyJtadvU2LDaOsHqhhV70AGsisc,4
 ply/__init__.py,sha256=sx6iBIF__WKIeU0iw2WSoSqBhclHF5EhBTc0wDigTV8,103
-ply/__pycache__/__init__.cpython-311.pyc,,
-ply/__pycache__/cpp.cpython-311.pyc,,
-ply/__pycache__/ctokens.cpython-311.pyc,,
-ply/__pycache__/lex.cpython-311.pyc,,
-ply/__pycache__/yacc.cpython-311.pyc,,
-ply/__pycache__/ygen.cpython-311.pyc,,
 ply/cpp.py,sha256=KTg13R5SKeicwZm7bIPL44KcQBRcHsmeEGOwIBVvLko,33639
 ply/ctokens.py,sha256=GmyWYDY9nl6F1WJQ9rmcQFgh1FnADFlnp_TBjTcEsqU,3155
 ply/lex.py,sha256=babRISnIAfzHo7WqLYF2qGCSaH0btM8d3ztgHaK3SA0,42905
diff --git a/server/libs/pygls-1.3.1.dist-info/INSTALLER b/server/libs/pygls-1.3.1.dist-info/INSTALLER
index a1b589e..5c69047 100644
--- a/server/libs/pygls-1.3.1.dist-info/INSTALLER
+++ b/server/libs/pygls-1.3.1.dist-info/INSTALLER
@@ -1 +1 @@
-pip
+uv
\ No newline at end of file
diff --git a/server/libs/pygls-1.3.1.dist-info/RECORD b/server/libs/pygls-1.3.1.dist-info/RECORD
index 518102f..950a21a 100644
--- a/server/libs/pygls-1.3.1.dist-info/RECORD
+++ b/server/libs/pygls-1.3.1.dist-info/RECORD
@@ -1,34 +1,19 @@
-pygls-1.3.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+pygls-1.3.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
 pygls-1.3.1.dist-info/LICENSE.txt,sha256=b0kVxr8adbxhHDGM8t6T3jWLMbQJ7QLrngwkWnnWCl8,11367
 pygls-1.3.1.dist-info/METADATA,sha256=ZZmXz51Jk7TTtWO1hLfSWQynv6rDCM798YWVbloCqHk,4726
 pygls-1.3.1.dist-info/RECORD,,
 pygls-1.3.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
 pygls-1.3.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
 pygls/__init__.py,sha256=rdTb3X-53tCjgNpS5dRsxv0ukMDjEly2pVEOPTnjx5k,1488
-pygls/__pycache__/__init__.cpython-311.pyc,,
-pygls/__pycache__/capabilities.cpython-311.pyc,,
-pygls/__pycache__/client.cpython-311.pyc,,
-pygls/__pycache__/constants.cpython-311.pyc,,
-pygls/__pycache__/exceptions.cpython-311.pyc,,
-pygls/__pycache__/feature_manager.cpython-311.pyc,,
-pygls/__pycache__/progress.cpython-311.pyc,,
-pygls/__pycache__/server.cpython-311.pyc,,
-pygls/__pycache__/uris.cpython-311.pyc,,
 pygls/capabilities.py,sha256=3tl-cqu82QpxHz-Z3NtlU6z-gbPqIvyvZ6gyZ-dr0-0,16756
 pygls/client.py,sha256=loVwqagoY0BnS6RwfpYtXwhg0NiIE6Tvbj2DzpOF7GA,6292
 pygls/constants.py,sha256=0YsX4Egp9jVLuAu9v8T_ThrObrVKGP916-X-9bkmId8,1470
 pygls/exceptions.py,sha256=skJKYaJCXI5At2eS1pNdQ6r3_B9Z-SMlyqr0_dwUVEw,6302
 pygls/feature_manager.py,sha256=7C--ra3GaG44LoZd8MaZ6Jh_8uxQMZo5jzykGwT7Fdg,8494
 pygls/lsp/__init__.py,sha256=pp9PCQhGzgPPFfWID47cdz_Q0LpZjT2kFPgTS-N7iVM,5236
-pygls/lsp/__pycache__/__init__.cpython-311.pyc,,
-pygls/lsp/__pycache__/client.cpython-311.pyc,,
 pygls/lsp/client.py,sha256=8JVgyjXXOblPlDhLAvNbukK-qrQNf__757elz9XYkCo,76358
 pygls/progress.py,sha256=Ml8vgJ9ueFC4YUqwvrgHatX8l_O3odhvmoPOLIcMNok,2789
 pygls/protocol/__init__.py,sha256=YI5xMBILWYKexx343wUDrHUVPuJFu9A48VSf0ieXYJM,1822
-pygls/protocol/__pycache__/__init__.cpython-311.pyc,,
-pygls/protocol/__pycache__/json_rpc.cpython-311.pyc,,
-pygls/protocol/__pycache__/language_server.cpython-311.pyc,,
-pygls/protocol/__pycache__/lsp_meta.cpython-311.pyc,,
 pygls/protocol/json_rpc.py,sha256=01nqJoCNDmZQ78tyFxPfA9_kEBHyuBe-7ZUXCqwmu4g,20124
 pygls/protocol/language_server.py,sha256=b6X30DCLN4sdG85OhLVdrBJ9cweL4eKVgsc10bnMzXk,20109
 pygls/protocol/lsp_meta.py,sha256=kX1nL7XGVIYWp6UUyT3yDFnhcvpoCU_3mdyzpDTtUyQ,1593
@@ -36,10 +21,6 @@ pygls/py.typed,sha256=ZfGKUcVseOxYpg6BU9EuhkP4dErsepCA4apkj_9YnYc,65
 pygls/server.py,sha256=T-k2wsP0W5a6KHmE9Afrv2PTy3qACEuCPGSJPmzC30w,20753
 pygls/uris.py,sha256=lknA_8hNYfs47mOFQxnFYW2TWH-8iL68ghTVD0Cccsc,5764
 pygls/workspace/__init__.py,sha256=tD6ahYMIPVsDdsWJ32EhF8wK-IJy6IQGInkI-BmIAxY,2883
-pygls/workspace/__pycache__/__init__.cpython-311.pyc,,
-pygls/workspace/__pycache__/position_codec.cpython-311.pyc,,
-pygls/workspace/__pycache__/text_document.cpython-311.pyc,,
-pygls/workspace/__pycache__/workspace.cpython-311.pyc,,
 pygls/workspace/position_codec.py,sha256=UDv1kXFMyCDnu-iGhEbylCJP74L0TAL2hFvhoFrSpOY,8019
 pygls/workspace/text_document.py,sha256=8LcxsQeawuPNDrNDm3A2oMjWxYA3YxXm3NiHyQJQp8M,9031
 pygls/workspace/workspace.py,sha256=Zpd96kvVM7po7cI_XLenRAq8Wp3mjlMBX_YxO6Ecvs8,11556
diff --git a/server/libs/tclint-0.6.0.dist-info/INSTALLER b/server/libs/tclint-0.6.0.dist-info/INSTALLER
deleted file mode 100644
index a1b589e..0000000
--- a/server/libs/tclint-0.6.0.dist-info/INSTALLER
+++ /dev/null
@@ -1 +0,0 @@
-pip
diff --git a/server/libs/tclint-0.6.0.dist-info/RECORD b/server/libs/tclint-0.6.0.dist-info/RECORD
deleted file mode 100644
index 6f60013..0000000
--- a/server/libs/tclint-0.6.0.dist-info/RECORD
+++ /dev/null
@@ -1,51 +0,0 @@
-../../bin/tclfmt.exe,sha256=8ZZ_4y2Bn-gjRcDczvxwNhwRfZ-MlnkLw5NSFVnorMI,108435
-../../bin/tclint.exe,sha256=qChLBArPHW4hqqqd04-iDo9uoAQoNYKKCYZWdgaifhc,108435
-../../bin/tclsp.exe,sha256=-GEYOcDbCgp7WBq2zcwTNxO7RM-3vBQUG62K2aqWuCY,108434
-tclint-0.6.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-tclint-0.6.0.dist-info/METADATA,sha256=FMBk7GnSMWPVKrjBqk6Lxu4Wyf0-xG-Fne14sDdfuvA,4061
-tclint-0.6.0.dist-info/RECORD,,
-tclint-0.6.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-tclint-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
-tclint-0.6.0.dist-info/entry_points.txt,sha256=IKl_khZUS1DefUWuXWPY2cG0cLD1FRMh3qrhXIOy7O8,112
-tclint-0.6.0.dist-info/licenses/LICENSE,sha256=PGii0wulXro34f25070gTG-JRGM-TAyXyKVObnNJU68,1055
-tclint-0.6.0.dist-info/top_level.txt,sha256=_cnnEELsoakzUgD9HHdivUoNbpKG9tUGznWvbGLmHQM,7
-tclint/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-tclint/__main__.py,sha256=b18i_1-ZvzsA-TjgZUbH7MNVwbmsoiqIXZrg0lUvJNI,64
-tclint/__pycache__/__init__.cpython-311.pyc,,
-tclint/__pycache__/__main__.cpython-311.pyc,,
-tclint/__pycache__/_version.cpython-311.pyc,,
-tclint/__pycache__/checks.cpython-311.pyc,,
-tclint/__pycache__/comments.cpython-311.pyc,,
-tclint/__pycache__/config.cpython-311.pyc,,
-tclint/__pycache__/format.cpython-311.pyc,,
-tclint/__pycache__/lexer.cpython-311.pyc,,
-tclint/__pycache__/parser.cpython-311.pyc,,
-tclint/__pycache__/syntax_tree.cpython-311.pyc,,
-tclint/__pycache__/violations.cpython-311.pyc,,
-tclint/_version.py,sha256=jF9TuoEIJRaca3ScKo6qaz6PzaMlu7jjuSQIrJ3nX4U,511
-tclint/checks.py,sha256=u6EI1V0fNRhcFAGBeKdXlHrCHrOD0cUAyXpzYgzBcq8,6872
-tclint/cli/__pycache__/tclfmt.cpython-311.pyc,,
-tclint/cli/__pycache__/tclint.cpython-311.pyc,,
-tclint/cli/__pycache__/tclsp.cpython-311.pyc,,
-tclint/cli/__pycache__/utils.cpython-311.pyc,,
-tclint/cli/tclfmt.py,sha256=jeVLuPkGUkTx2RTqmdcPZu13m7x43DQwlumXFwlL5oA,5517
-tclint/cli/tclint.py,sha256=RYWm_41AUOdbY0fXOSkOy1-sMVYUmwPyRklG-DOa3dw,4488
-tclint/cli/tclsp.py,sha256=martVsLCMZQKOg0WAZuWLJ8wO2wwh0LVVrkFcbLcFMY,14633
-tclint/cli/utils.py,sha256=eaGUozjoyJecVOHMU_fFJWCv1JoNdH_eonj5os_6XB8,2924
-tclint/commands/__init__.py,sha256=CQVM2J2JOIWkt8GIxbIeCIqQTBmuwKzVO6f1eguwix0,1113
-tclint/commands/__pycache__/__init__.cpython-311.pyc,,
-tclint/commands/__pycache__/builtin.cpython-311.pyc,,
-tclint/commands/__pycache__/checks.cpython-311.pyc,,
-tclint/commands/__pycache__/plugins.cpython-311.pyc,,
-tclint/commands/__pycache__/schema.cpython-311.pyc,,
-tclint/commands/builtin.py,sha256=ny6ERMBsqesK0ML46oVWwOlKuU4Wqq7G8NvkOQbn_Ec,36147
-tclint/commands/checks.py,sha256=7ia0ZbhTLHvSbwVimU2wnEqB1SeRdyC40VMui8s7Z3I,8556
-tclint/commands/plugins.py,sha256=OX42Dm9wXoGUnPUAr4PWj9zWBu7huz6O2YKEqlqprck,2640
-tclint/commands/schema.py,sha256=nPXrgxOl5codb0RtRp-kUI85ML76ZF58eadGanZq3S8,979
-tclint/comments.py,sha256=j50bULPa_l7yhHVJw2HJF0AQMP5_dOYtL78haMIw5kA,3019
-tclint/config.py,sha256=-JM6DtkznWpnJ-wDKxc-uONGjBlmY1--73k-AmTnrZs,13801
-tclint/format.py,sha256=DyYejVdV_gOFkbuo7U2DezoEz129hRHe5hbg61XMHWM,16799
-tclint/lexer.py,sha256=I81EHH47no0ljjpfa0bHNTJrkZqGgU6-AgH0XeIK_5Q,6100
-tclint/parser.py,sha256=fInbPEXRDfgAjf5DX8I7exQwJaRd4qk6FULO-eWVMeM,28223
-tclint/syntax_tree.py,sha256=tfG4_Ff_AD_tGTHID_pW2Qdz7gRH-3NnfyK3hIj-zeg,11727
-tclint/violations.py,sha256=g2nYpPViuxL6LqLL64_kkGzeGxpTZQ1L9z5jusBGFEA,1219
diff --git a/server/libs/tclint-0.8.0.dist-info/INSTALLER b/server/libs/tclint-0.8.0.dist-info/INSTALLER
new file mode 100644
index 0000000..5c69047
--- /dev/null
+++ b/server/libs/tclint-0.8.0.dist-info/INSTALLER
@@ -0,0 +1 @@
+uv
\ No newline at end of file
diff --git a/server/libs/tclint-0.6.0.dist-info/METADATA b/server/libs/tclint-0.8.0.dist-info/METADATA
similarity index 83%
rename from server/libs/tclint-0.6.0.dist-info/METADATA
rename to server/libs/tclint-0.8.0.dist-info/METADATA
index ca24afa..2865849 100644
--- a/server/libs/tclint-0.6.0.dist-info/METADATA
+++ b/server/libs/tclint-0.8.0.dist-info/METADATA
@@ -1,10 +1,10 @@
 Metadata-Version: 2.4
 Name: tclint
-Version: 0.6.0
+Version: 0.8.0
 Summary: A CLI utility for linting and analyzing Tcl code.
 Author-email: Noah Moroze 
 License: MIT License
-Requires-Python: >=3.9
+Requires-Python: >=3.10
 Description-Content-Type: text/markdown
 License-File: LICENSE
 Requires-Dist: ply==3.11
@@ -13,13 +13,6 @@ Requires-Dist: pathspec==0.11.2
 Requires-Dist: importlib-metadata==6.8.0
 Requires-Dist: pygls==1.3.1
 Requires-Dist: voluptuous==0.15.2
-Provides-Extra: dev
-Requires-Dist: black; extra == "dev"
-Requires-Dist: flake8; extra == "dev"
-Requires-Dist: pytest; extra == "dev"
-Requires-Dist: pytest-timeout; extra == "dev"
-Requires-Dist: codespell; extra == "dev"
-Requires-Dist: pytest-lsp; extra == "dev"
 Dynamic: license-file
 
 # tclint   [![CI](https://github.com/nmoroze/tclint/actions/workflows/ci.yml/badge.svg)](https://github.com/nmoroze/tclint/actions/workflows/ci.yml)
@@ -76,7 +69,9 @@ Collected files will be checked for lint violations.  See the
 [Violations](docs/violations.md) documentation page for a description of all
 lint violations `tclint` may report.
 
-Aspects of `tclint`'s behavior can be controlled by a configuration file. By default, `tclint` will look for a file named `tclint.toml` or `.tclint` in the current working directory (in that order), but a path to an alternate configuration file can be provided using the `-c` or `--config` flag. See [Configuration](docs/configuration.md) for documentation on the configuration file.
+Aspects of `tclint`'s behavior can be controlled by a configuration file.
+By default, `tclint` will search for a file named `tclint.toml` or `.tclint` by walking the parent directories of source files, but a path to an alternate configuration file can be provided using the `-c` or `--config` flag.
+See [Configuration](docs/configuration.md) for documentation on the configuration file.
 
 `tclint` includes a plugin system for checking EDA tool-specific commands. See the [Plugins](docs/plugins.md) documentation page for more info.
 
@@ -91,14 +86,18 @@ Use the following steps to set up `tclint` for local development:
 ```sh
 $ git clone https://github.com/nmoroze/tclint.git # or URL to fork
 $ cd tclint
-$ pip install -e .[dev]
+$ python3 -m venv .venv # set up a venv to ensure clean environment
+$ source .venv/bin/activate
+(venv) $ pip install --upgrade pip # development requires pip >= 25.1
+(venv) $ pip install -e . --group dev
 ```
 
 Please format, lint, and run tests before submitting changes:
 
 ```sh
-$ black --preview .
-$ ./util/pre-commit
+$ source .venv/bin/activate
+(venv) $ black .
+(venv) $ ./util/pre-commit
 ```
 
 ## License
diff --git a/server/libs/tclint-0.8.0.dist-info/RECORD b/server/libs/tclint-0.8.0.dist-info/RECORD
new file mode 100644
index 0000000..b96e4dc
--- /dev/null
+++ b/server/libs/tclint-0.8.0.dist-info/RECORD
@@ -0,0 +1,34 @@
+bin/tclfmt.exe,sha256=5o9LOeyU_bfji3TOEQ2sDsjitSg-ZAQISI2LSX9MrCg,47104
+bin/tclint.exe,sha256=0rGm-shW-XmBQ_KQkCJvGa9Qe1GCHvErXuL6C1D_kjc,47104
+bin/tclsp.exe,sha256=K9fidz6r40fs43dAcfHVRE8RkH7W0dtQxX42EpMBp6I,47104
+tclint-0.8.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
+tclint-0.8.0.dist-info/METADATA,sha256=QiY1JEN7FiNRwRYDcqb2OqYucGyVl5ddIZIm1vC3WQ0,4015
+tclint-0.8.0.dist-info/RECORD,,
+tclint-0.8.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+tclint-0.8.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
+tclint-0.8.0.dist-info/entry_points.txt,sha256=K2vcacREnbnOEZvE7-QfzsmF9vazrD7HgM2WmTl_Wgs,161
+tclint-0.8.0.dist-info/licenses/LICENSE,sha256=PGii0wulXro34f25070gTG-JRGM-TAyXyKVObnNJU68,1055
+tclint-0.8.0.dist-info/top_level.txt,sha256=_cnnEELsoakzUgD9HHdivUoNbpKG9tUGznWvbGLmHQM,7
+tclint/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+tclint/__main__.py,sha256=Bp_AE4xH3XNsUq3EHISWdLOFUbdvwnZ6YSf3F1NIqWU,65
+tclint/_version.py,sha256=Rttl-BDadtcW1QzGnNffCWA_Wc9mUKDMOBPZp--Mnsc,704
+tclint/checks.py,sha256=wr29RqWd8zuF9gy5YBWjkZs1WQ91pj9ipkfdsxVuDvg,7863
+tclint/cli/resolver.py,sha256=kDvSvbQiqk_Dhd7zBkiUrSDkplr02uhdZ6k3CFj6geg,3750
+tclint/cli/tclfmt.py,sha256=8_d7TGmV8gU6VfRhxjFxmlTDt1zBmKtk3016CvdnpXo,6949
+tclint/cli/tclint.py,sha256=CTmchC3iL-dEM0paeRVEKJf79cEnUh7_QAQ7c17UAd0,4548
+tclint/cli/tclsp.py,sha256=TvBA0K2_6CkRKgHXiTSFIDcNykOhH-qHOox7qQABMaY,18051
+tclint/cli/utils.py,sha256=qa1tJ3St0uC9f4yAKCC4atglu8R3fZYVxu-KZ0dqTAo,1926
+tclint/commands/__init__.py,sha256=u0zk3di692M1QSZGvOEM7ZYzKBB7r950ytAS8cxmEo0,113
+tclint/commands/builtin.py,sha256=XO0z4lX6rbEIpjZIEJ5-nGc0mX8a8VZxpAmOTCNIGSA,41587
+tclint/commands/checks.py,sha256=7SkderCq_M36XbOJbNw5ZDKt1XXlmT4ErAG4K8eY4-k,14133
+tclint/commands/plugins.py,sha256=Q-FqS3h-D6m0lWzdMH5WFSEF19Ol2vTgPySb2ydCg5Q,5407
+tclint/commands/schema.py,sha256=nzgPuyQviWZYn1FZYzKs1kGixvIu0RF2GUoXzd3iaLg,1123
+tclint/comments.py,sha256=kCfSdnoSVofvNqs4zxnOrg45shlbVvu-e2hKia6EXhk,3047
+tclint/config.py,sha256=d4QUS9XwZuBNGv9ggz0QpD7jbAXmWVBrenKzGjexx-E,15285
+tclint/format.py,sha256=YXX6HRy_AOhcBcJL3Ofo_lAdHtjPAGd0piiVTaGKZzg,21753
+tclint/lexer.py,sha256=JnDcYCeT8wCGV1kqjXRVjQDUBw48lWBx_5g_4dt-JLc,6069
+tclint/parser.py,sha256=sl7bkhKN253VpBALin8Bd5FtNxRfi5cEy7NFYtvzPpo,28822
+tclint/plugins/expect.py,sha256=5fbrwJK3108w20Ib7pvfJhgVNpSW3FTAovlhbspDsIQ,1912
+tclint/symbol_table.py,sha256=vSbE5Cbgb-_ppdjsXOR4WBNqPQXxhf0k-pZMOFe5vU8,1557
+tclint/syntax_tree.py,sha256=kkMtbxUkD1EcLT2bXsV5foDF2W6zAYoxzu-dPZGnBbs,13033
+tclint/violations.py,sha256=q9y0j1btlMLhy1Pn6HP0u1347d7SF4t3ZVZpIxH3MVc,1232
diff --git a/server/libs/typing_extensions-4.14.1.dist-info/REQUESTED b/server/libs/tclint-0.8.0.dist-info/REQUESTED
similarity index 100%
rename from server/libs/typing_extensions-4.14.1.dist-info/REQUESTED
rename to server/libs/tclint-0.8.0.dist-info/REQUESTED
diff --git a/server/libs/tclint-0.6.0.dist-info/WHEEL b/server/libs/tclint-0.8.0.dist-info/WHEEL
similarity index 65%
rename from server/libs/tclint-0.6.0.dist-info/WHEEL
rename to server/libs/tclint-0.8.0.dist-info/WHEEL
index e7fa31b..14a883f 100644
--- a/server/libs/tclint-0.6.0.dist-info/WHEEL
+++ b/server/libs/tclint-0.8.0.dist-info/WHEEL
@@ -1,5 +1,5 @@
 Wheel-Version: 1.0
-Generator: setuptools (80.9.0)
+Generator: setuptools (82.0.1)
 Root-Is-Purelib: true
 Tag: py3-none-any
 
diff --git a/server/libs/tclint-0.6.0.dist-info/entry_points.txt b/server/libs/tclint-0.8.0.dist-info/entry_points.txt
similarity index 69%
rename from server/libs/tclint-0.6.0.dist-info/entry_points.txt
rename to server/libs/tclint-0.8.0.dist-info/entry_points.txt
index c182ada..20a98b5 100644
--- a/server/libs/tclint-0.6.0.dist-info/entry_points.txt
+++ b/server/libs/tclint-0.8.0.dist-info/entry_points.txt
@@ -2,3 +2,6 @@
 tclfmt = tclint.cli.tclfmt:main
 tclint = tclint.cli.tclint:main
 tclsp = tclint.cli.tclsp:main
+
+[tclint.plugins]
+expect = tclint.plugins.expect
diff --git a/server/libs/tclint-0.6.0.dist-info/licenses/LICENSE b/server/libs/tclint-0.8.0.dist-info/licenses/LICENSE
similarity index 100%
rename from server/libs/tclint-0.6.0.dist-info/licenses/LICENSE
rename to server/libs/tclint-0.8.0.dist-info/licenses/LICENSE
diff --git a/server/libs/tclint-0.6.0.dist-info/top_level.txt b/server/libs/tclint-0.8.0.dist-info/top_level.txt
similarity index 100%
rename from server/libs/tclint-0.6.0.dist-info/top_level.txt
rename to server/libs/tclint-0.8.0.dist-info/top_level.txt
diff --git a/server/libs/tclint/__main__.py b/server/libs/tclint/__main__.py
index 95fa5d0..08aea26 100644
--- a/server/libs/tclint/__main__.py
+++ b/server/libs/tclint/__main__.py
@@ -1,4 +1,5 @@
 import sys
+
 from tclint.cli.tclint import main
 
 sys.exit(main())
diff --git a/server/libs/tclint/_version.py b/server/libs/tclint/_version.py
index 92633a5..c84591a 100644
--- a/server/libs/tclint/_version.py
+++ b/server/libs/tclint/_version.py
@@ -1,7 +1,14 @@
 # file generated by setuptools-scm
 # don't change, don't track in version control
 
-__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
+__all__ = [
+    "__version__",
+    "__version_tuple__",
+    "version",
+    "version_tuple",
+    "__commit_id__",
+    "commit_id",
+]
 
 TYPE_CHECKING = False
 if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
     from typing import Union
 
     VERSION_TUPLE = Tuple[Union[int, str], ...]
+    COMMIT_ID = Union[str, None]
 else:
     VERSION_TUPLE = object
+    COMMIT_ID = object
 
 version: str
 __version__: str
 __version_tuple__: VERSION_TUPLE
 version_tuple: VERSION_TUPLE
+commit_id: COMMIT_ID
+__commit_id__: COMMIT_ID
 
-__version__ = version = '0.6.0'
-__version_tuple__ = version_tuple = (0, 6, 0)
+__version__ = version = '0.8.0'
+__version_tuple__ = version_tuple = (0, 8, 0)
+
+__commit_id__ = commit_id = None
diff --git a/server/libs/tclint/checks.py b/server/libs/tclint/checks.py
index 8566823..7ee7e8d 100644
--- a/server/libs/tclint/checks.py
+++ b/server/libs/tclint/checks.py
@@ -1,16 +1,17 @@
 import re
 
-from tclint.commands import get_commands
-from tclint.violations import Rule, Violation
-
+from tclint.commands.plugins import PluginManager
+from tclint.config import Config
 from tclint.syntax_tree import (
-    Visitor,
     BracedExpression,
-    Expression,
     BracedWord,
-    QuotedWord,
     CommandSub,
+    Expression,
+    QuotedWord,
+    Script,
+    Visitor,
 )
+from tclint.violations import Rule, Violation
 
 
 class LineLengthChecker:
@@ -81,11 +82,13 @@ class RedefinedBuiltinChecker(Visitor):
     Reports 'redefined-builtin' violations.
     """
 
-    def check(self, _, tree, config):
-        self._violations = []
+    def __init__(self, plugin_manager: PluginManager):
+        self._plugin_manager = plugin_manager
 
-        plugins = [config.commands] if config.commands is not None else []
-        commands = get_commands(plugins)
+    def check(self, _, tree: Script, config: Config) -> list[Violation]:
+        self._violations: list[Violation] = []
+
+        commands = self._plugin_manager.get_commands(config.commands)
         self._commands = commands.keys()
 
         tree.accept(self, recurse=True)
@@ -115,8 +118,8 @@ class RedefinedBuiltinChecker(Visitor):
 
 
 class UnbracedExprChecker(Visitor):
-    def check(self, _, tree, __):
-        self._violations = []
+    def check(self, _, tree, __) -> list[Violation]:
+        self._violations: list[Violation] = []
         tree.accept(self, recurse=True)
         return self._violations
 
@@ -170,8 +173,8 @@ class UnbracedExprChecker(Visitor):
 
 
 class RedundantExprChecker(Visitor):
-    def check(self, _, tree, __):
-        self._violations = []
+    def check(self, _, tree, __) -> list[Violation]:
+        self._violations: list[Violation] = []
         tree.accept(self, recurse=True)
         return self._violations
 
@@ -215,11 +218,33 @@ class RedundantExprChecker(Visitor):
             self._check_operand(arg)
 
 
-def get_checkers():
+class UnopenedQuoteChecker(Visitor):
+    # Matches a literal " not preceded by backslash (escaped quotes are intentional)
+    BARE_QUOTE_RE = re.compile(r'(? list[Violation]:
+        self._violations: list[Violation] = []
+        tree.accept(self, recurse=True)
+        return self._violations
+
+    def visit_bare_word(self, word):
+        if self.BARE_QUOTE_RE.search(word.value):
+            self._violations.append(
+                Violation(
+                    Rule.UNOPENED_QUOTE,
+                    'found " without opening quote',
+                    word.pos,
+                    word.end_pos,
+                )
+            )
+
+
+def get_checkers(plugin_manager: PluginManager):
     checkers = (
-        RedefinedBuiltinChecker(),
+        RedefinedBuiltinChecker(plugin_manager),
         UnbracedExprChecker(),
         RedundantExprChecker(),
+        UnopenedQuoteChecker(),
         LineLengthChecker(),
         TrailingWhitespaceChecker(),
     )
diff --git a/server/libs/tclint/cli/resolver.py b/server/libs/tclint/cli/resolver.py
new file mode 100644
index 0000000..672abcf
--- /dev/null
+++ b/server/libs/tclint/cli/resolver.py
@@ -0,0 +1,98 @@
+import os
+from pathlib import Path
+from typing import Optional
+
+from tclint.cli.utils import make_exclude_filter
+from tclint.config import Config, load_config_at
+
+
+class Resolver:
+    def __init__(self, cli_args=None, global_config: Optional[Config] = None):
+        # TODO: Figure out a way to annotate cli_args type effectively.
+        self._config_cache: dict[Path, Config] = {}
+        self._global_config = global_config
+
+        # Instantiate and hang on to a default config so the Resolver can cache many
+        # instances of it without blowing up memory footprint.  (This has not been
+        # empirically validated!)
+        self._default_config = Config()
+        self._cli_args = None
+        if cli_args is not None:
+            self._default_config.apply_cli_args(cli_args)
+            self._cli_args = cli_args
+
+    def _find_config(self, directory: Path) -> Config:
+        config = load_config_at(directory)
+        if config is not None:
+            if self._cli_args is not None:
+                config.apply_cli_args(self._cli_args)
+            return config
+
+        # We're at the root, bail!
+        if directory.parent == directory:
+            return self._default_config
+
+        return self.find_config(directory.parent)
+
+    def find_config(self, directory: Path) -> Config:
+        if self._global_config is not None:
+            return self._global_config
+
+        if directory in self._config_cache:
+            return self._config_cache[directory]
+        config = self._find_config(directory)
+        self._config_cache[directory] = config
+        return config
+
+    def resolve_sources(
+        self, paths: list[Path], cwd: Path
+    ) -> list[tuple[Optional[Path], Config]]:
+        sources: list[tuple[Optional[Path], Config]] = []
+
+        for path in paths:
+            if str(path) == "-":
+                config = self.find_config(cwd)
+                sources.append((None, config))
+                continue
+
+            if not path.exists():
+                raise FileNotFoundError(f"path {path} does not exist")
+
+            # We need to slap a .resolve() on the find_config()'s to make sure the
+            # method traverses upwards to the FS root. It would probably ideal to just
+            # resolve each path in the main body of the loop, but this actually has
+            # implications on how the filepath is printed out (it becomes an absolute
+            # path, symlinks are resolved).
+
+            if not path.is_dir():
+                config = self.find_config(path.resolve().parent)
+                is_excluded = make_exclude_filter(config.exclude)
+                if is_excluded(path):
+                    continue
+                sources.append((path, config))
+                continue
+
+            for dirstr, dirs, filenames in os.walk(path):
+                dirpath = Path(dirstr)
+                config = self.find_config(dirpath.resolve())
+                is_excluded = make_exclude_filter(config.exclude)
+                extensions = [
+                    f".{ext}" if not ext.startswith(".") else ext
+                    for ext in config.extensions
+                ]
+
+                # Update dirs to prune next directories to traverse based on exclude.
+                to_traverse = []
+                for dir in dirs:
+                    if not is_excluded(Path(dir)):
+                        to_traverse.append(dir)
+                dirs[:] = to_traverse
+
+                for name in filenames:
+                    _, ext = os.path.splitext(name)
+                    if ext.lower() in extensions:
+                        child = dirpath / name
+                        if not is_excluded(child):
+                            sources.append((child, config))
+
+        return sources
diff --git a/server/libs/tclint/cli/tclfmt.py b/server/libs/tclint/cli/tclfmt.py
index cc7c8be..90e4dce 100644
--- a/server/libs/tclint/cli/tclfmt.py
+++ b/server/libs/tclint/cli/tclfmt.py
@@ -4,16 +4,17 @@ import argparse
 import pathlib
 import sys
 
-from tclint.cli.utils import resolve_sources, register_codec_warning
+from tclint.cli.resolver import Resolver
+from tclint.cli.utils import register_codec_warning
+from tclint.commands.plugins import PluginManager
 from tclint.config import (
-    get_config,
-    setup_tclfmt_config_cli_args,
     Config,
     ConfigError,
-    RunConfig,
+    SpacesInBraces,
+    setup_tclfmt_config_cli_args,
 )
-from tclint.parser import Parser, TclSyntaxError
 from tclint.format import Formatter, FormatterOpts
+from tclint.parser import Parser, TclSyntaxError
 
 try:
     from tclint._version import __version__  # type: ignore
@@ -27,22 +28,41 @@ EXIT_SYNTAX_ERROR = 2
 EXIT_INPUT_ERROR = 4
 
 
-def format(script: str, config: Config, debug=False) -> str:
-    plugins = [config.commands] if config.commands is not None else []
-    parser = Parser(debug=debug, command_plugins=plugins)
+def format(
+    script: str,
+    config: Config,
+    plugins: PluginManager,
+    debug=False,
+    debug_whitespace=False,
+    partial=False,
+) -> str:
+    parser = Parser(debug=debug, commands=plugins.get_commands(config.commands))
 
     formatter = Formatter(
         FormatterOpts(
             indent=config.get_indent(),
-            spaces_in_braces=config.style_spaces_in_braces,
+            indent_mixed_tab_size=config.get_indent_mixed_tab_size(),
+            spaces_in_braces=(
+                config.style_spaces_in_braces == SpacesInBraces.ALWAYS
+                or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
+            ),
+            balanced_spaces_in_braces=(
+                config.style_spaces_in_braces == SpacesInBraces.BALANCED_NO
+                or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
+            ),
             max_blank_lines=config.style_max_blank_lines,
             indent_namespace_eval=config.style_indent_namespace_eval,
+            emacs=config.style_emacs,
+            debug_whitespace=debug_whitespace,
         )
     )
-    return formatter.format_top(script, parser)
+    if partial:
+        return formatter.format_partial(script, parser)
+    else:
+        return formatter.format_top(script, parser)
 
 
-def check(path: pathlib.Path, script: str, formatted: str):
+def check(path: str, script: str, formatted: str):
     parser = Parser()
     original_tree = parser.parse(script)
     formatted_tree = parser.parse(formatted)
@@ -86,6 +106,12 @@ def main():
             " of output (e.g. -dd)"
         ),
     )
+    parser.add_argument(
+        "--debug-whitespace",
+        action="store_true",
+        default=False,
+        help="display whitespace in debug mode.",
+    )
     parser.add_argument(
         "-c",
         "--config",
@@ -94,41 +120,46 @@ def main():
         default=None,
         metavar="",
     )
-    setup_tclfmt_config_cli_args(parser)
+    parser.add_argument(
+        "--partial",
+        help="treat input as a fragment of a script",
+        action="store_true",
+    )
+
+    cwd = pathlib.Path.cwd()
+    setup_tclfmt_config_cli_args(parser, cwd)
     args = parser.parse_args()
 
+    global_config = None
+    if args.config is not None:
+        try:
+            global_config = Config.from_path(args.config, cwd)
+            global_config.apply_cli_args(args)
+        except FileNotFoundError:
+            print(f"Config file path doesn't exist: {args.config}")
+            return EXIT_INPUT_ERROR
+        except ConfigError as e:
+            print(f"Invalid config file: {e}")
+            return EXIT_INPUT_ERROR
+
+    resolver = Resolver(args, global_config)
     try:
-        config = get_config(args.config, pathlib.Path.cwd())
+        sources = resolver.resolve_sources(args.source, cwd)
+    except FileNotFoundError as e:
+        print(f"Invalid path provided: {e}")
+        return EXIT_INPUT_ERROR
     except ConfigError as e:
         print(f"Invalid config file: {e}")
         return EXIT_INPUT_ERROR
 
-    if config is None:
-        config = RunConfig()
-
-    config.apply_cli_args(args)
-
-    try:
-        # TODO: we should eventually allow tclfmt to find a config by walking up
-        # directories, at which point exclude_root should be the parent dir of
-        # the config file, unless -c is used (eslint rules)
-        exclude_root = pathlib.Path.cwd()
-        sources = resolve_sources(
-            args.source,
-            exclude_patterns=config.exclude,
-            exclude_root=exclude_root,
-            extensions=config.extensions,
-        )
-    except FileNotFoundError as e:
-        print(f"Invalid path provided: {e}")
-        return EXIT_INPUT_ERROR
+    plugin_manager = PluginManager(trust_uninstalled=args.trust_plugins)
 
     retcode = EXIT_OK
 
     register_codec_warning("replace_with_warning")
 
     reformat_count = 0
-    for path in sources:
+    for path, config in sources:
         if path is None:
             script = sys.stdin.read()
             out_prefix = "(stdin)"
@@ -139,7 +170,12 @@ def main():
 
         try:
             formatted = format(
-                script, config.get_for_path(path), debug=(args.debug > 1)
+                script,
+                config,
+                plugin_manager,
+                debug=(args.debug > 1),
+                debug_whitespace=args.debug_whitespace,
+                partial=args.partial,
             )
             if args.in_place and path:
                 with open(path, "w") as f:
@@ -155,9 +191,15 @@ def main():
                 print(formatted, end="")
 
             if args.debug > 0:
-                check(path, script, formatted)
+                if args.debug_whitespace:
+                    print(
+                        "Warning: --debug-whitespace enabled, disabling original vs."
+                        " formatted syntax tree check"
+                    )
+                else:
+                    check(out_prefix, script, formatted)
         except TclSyntaxError as e:
-            line, col = e.pos
+            line, col = e.start
             print(f"{out_prefix}:{line}:{col}: syntax error: {e}", file=sys.stderr)
             retcode |= EXIT_SYNTAX_ERROR
             continue
diff --git a/server/libs/tclint/cli/tclint.py b/server/libs/tclint/cli/tclint.py
index 6afd8b1..60c0dfd 100644
--- a/server/libs/tclint/cli/tclint.py
+++ b/server/libs/tclint/cli/tclint.py
@@ -3,21 +3,16 @@
 import argparse
 import pathlib
 import sys
-from typing import Dict, List, Optional
+from typing import Optional
 
-
-from tclint.config import (
-    get_config,
-    setup_config_cli_args,
-    Config,
-    ConfigError,
-    RunConfig,
-)
-from tclint.parser import Parser, TclSyntaxError
 from tclint.checks import get_checkers
-from tclint.violations import Violation, Rule
+from tclint.cli.resolver import Resolver
+from tclint.cli.utils import register_codec_warning
+from tclint.commands.plugins import PluginManager
 from tclint.comments import CommentVisitor
-from tclint.cli.utils import resolve_sources, register_codec_warning
+from tclint.config import Config, ConfigError, setup_config_cli_args
+from tclint.parser import Parser, TclSyntaxError
+from tclint.violations import Rule, Violation
 
 try:
     from tclint._version import __version__  # type: ignore
@@ -32,10 +27,10 @@ EXIT_INPUT_ERROR = 4
 
 
 def filter_violations(
-    violations: List[Violation],
-    config_ignore: List[Rule],
-    inline_ignore: Dict[int, List[Rule]],
-) -> List[Violation]:
+    violations: list[Violation],
+    config_ignore: list[Rule],
+    inline_ignore: dict[int, list[Rule]],
+) -> list[Violation]:
     filtered_violations = []
 
     for violation in violations:
@@ -53,11 +48,11 @@ def filter_violations(
 def lint(
     script: str,
     config: Config,
+    plugins: PluginManager,
     path: Optional[pathlib.Path],
     debug=0,
-) -> List[Violation]:
-    plugins = [config.commands] if config.commands is not None else []
-    parser = Parser(debug=(debug > 0), command_plugins=plugins)
+) -> list[Violation]:
+    parser = Parser(debug=(debug > 0), commands=plugins.get_commands(config.commands))
 
     violations = []
     tree = parser.parse(script)
@@ -66,7 +61,7 @@ def lint(
     if debug > 0:
         print(tree.pretty(positions=(debug > 1)))
 
-    for checker in get_checkers():
+    for checker in get_checkers(plugins):
         violations += checker.check(script, tree, config)
 
     v = CommentVisitor()
@@ -105,40 +100,40 @@ def main():
         default=None,
         metavar="",
     )
-    setup_config_cli_args(parser)
+
+    cwd = pathlib.Path.cwd()
+    setup_config_cli_args(parser, cwd)
     args = parser.parse_args()
 
+    global_config = None
+    if args.config is not None:
+        try:
+            global_config = Config.from_path(args.config, cwd)
+            global_config.apply_cli_args(args)
+        except FileNotFoundError:
+            print(f"Config file path doesn't exist: {args.config}")
+            return EXIT_INPUT_ERROR
+        except ConfigError as e:
+            print(f"Invalid config file: {e}")
+            return EXIT_INPUT_ERROR
+
+    resolver = Resolver(args, global_config)
     try:
-        config = get_config(args.config, pathlib.Path())
+        sources = resolver.resolve_sources(args.source, cwd)
+    except FileNotFoundError as e:
+        print(f"Invalid path provided: {e}")
+        return EXIT_INPUT_ERROR
     except ConfigError as e:
         print(f"Invalid config file: {e}")
         return EXIT_INPUT_ERROR
 
-    if config is None:
-        config = RunConfig()
-
-    config.apply_cli_args(args)
-
-    try:
-        # TODO: we should eventually allow tclint to find a config by walking up
-        # directories, at which point exclude_root should be the parent dir of
-        # the config file, unless -c is used (eslint rules)
-        exclude_root = pathlib.Path.cwd()
-        sources = resolve_sources(
-            args.source,
-            exclude_patterns=config.exclude,
-            exclude_root=exclude_root,
-            extensions=config.extensions,
-        )
-    except FileNotFoundError as e:
-        print(f"Invalid path provided: {e}")
-        return EXIT_INPUT_ERROR
+    plugin_manager = PluginManager(trust_uninstalled=args.trust_plugins)
 
     retcode = EXIT_OK
 
     register_codec_warning("replace_with_warning")
 
-    for path in sources:
+    for path, config in sources:
         if path is None:
             script = sys.stdin.read()
             out_prefix = "(stdin)"
@@ -150,7 +145,8 @@ def main():
         try:
             violations = lint(
                 script,
-                config.get_for_path(path),
+                config,
+                plugin_manager,
                 path,
                 debug=args.debug,
             )
diff --git a/server/libs/tclint/cli/tclsp.py b/server/libs/tclint/cli/tclsp.py
index b0b61dd..3bac696 100644
--- a/server/libs/tclint/cli/tclsp.py
+++ b/server/libs/tclint/cli/tclsp.py
@@ -1,22 +1,27 @@
 import argparse
 import dataclasses
 import logging
-from pathlib import Path
-from typing import Dict, List, Optional, Tuple
 import uuid
+from pathlib import Path
+from typing import Optional
 
 from lsprotocol import types as lsp
-
 from pygls.server import LanguageServer
-from pygls.workspace import TextDocument
 from pygls.uris import to_fs_path
+from pygls.workspace import TextDocument
 
-from tclint.cli import tclint
-from tclint.config import get_config, DEFAULT_CONFIGS, RunConfig, Config, ConfigError
+from tclint.cli import tclint, utils
+from tclint.commands.plugins import PluginManager
+from tclint.config import (
+    DEFAULT_CONFIGS,
+    Config,
+    ConfigError,
+    SpacesInBraces,
+    load_config_at,
+)
 from tclint.format import Formatter, FormatterOpts
 from tclint.lexer import TclSyntaxError
 from tclint.parser import Parser
-from tclint.cli import utils
 
 try:
     from tclint._version import __version__  # type: ignore
@@ -25,13 +30,14 @@ except ModuleNotFoundError:
 
 
 DIAGNOSTIC_SOURCE = "tclint"
+_DEFAULT_CONFIG = Config()
 
 
-def lint(source, config, path):
+def lint(source, config, plugin_manager, path):
     diagnostics = []
 
     try:
-        violations = tclint.lint(source, config, path)
+        violations = tclint.lint(source, config, plugin_manager, path)
     except TclSyntaxError as e:
         return [
             lsp.Diagnostic(
@@ -62,7 +68,7 @@ def lint(source, config, path):
                     start=start,
                     end=end,
                 ),
-                code=violation.id,
+                code=str(violation.id),
                 source=DIAGNOSTIC_SOURCE,
             )
         )
@@ -83,15 +89,26 @@ class TclspServer(LanguageServer):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.diagnostics = {}
-        self.global_config: RunConfig = None
-        # Maps workspace roots to configs.
-        self.configs: Dict[Path, RunConfig] = {}
+
+        # There are many config caches!!
+        # Caches loaded config files specified in LSP settings.
+        self.workspace_configs: dict[Path, Config] = {}
+        # Caches loaded config files present in open workspaces.
+        self.config_files: dict[Path, Config] = {}
+        # Caches which config is used by each open file.
+        self.source_configs: dict[Path, Config] = {}
+        # Tracks which invalid configs we've already displayed an error for, to avoid
+        # spam.
+        self.invalid_configs: set[Path] = set()
+
         self.client_supports_refresh = False
 
         self.global_settings = ExtensionSettings()
-        self.workspace_settings: Dict[Path, ExtensionSettings] = {}
+        self.workspace_settings: dict[Path, ExtensionSettings] = {}
 
-    def get_roots(self) -> List[Path]:
+        self.plugin_manager = PluginManager()
+
+    def get_roots(self) -> list[Path]:
         """Returns root folders currently open in the workspace."""
         roots = []
         for uri in self.workspace.folders.keys():
@@ -108,10 +125,8 @@ class TclspServer(LanguageServer):
         return roots
 
     def get_root(self, path: Path) -> Optional[Path]:
-        """Returns workspace root folder that's closest to path.
-
-        Returns None if path is not in a workspace folder or if there are no workspace
-        folders.
+        """Returns workspace root if path is in a workspace folder. Otherwise, returns
+        None.
         """
         roots = self.get_roots()
         closest_root = None
@@ -124,55 +139,98 @@ class TclspServer(LanguageServer):
             if len(relpath.parts) < distance:
                 distance = len(relpath.parts)
                 closest_root = root
+
         return closest_root
 
-    def get_config_file(self, workspace_root: Path) -> Optional[Path]:
-        if workspace_root in self.workspace_settings:
-            settings = self.workspace_settings[workspace_root]
+    def get_config_file(self, root: Path) -> Optional[Path]:
+        if root in self.workspace_settings:
+            settings = self.workspace_settings[root]
             return settings.config_file
         return self.global_settings.config_file
 
-    def load_configs(self):
-        self.configs = {}
+    def show_config_error(self, msg: str, path: Path):
+        if path not in self.invalid_configs:
+            self.show_message(f"Error loading config file: {msg}")
+        self.invalid_configs.add(path)
+
+    def load_config(self, path: Path, root: Path) -> Optional[Config]:
+        try:
+            return Config.from_path(path, root)
+        except FileNotFoundError:
+            self.show_config_error(f"{path} doesn't exist", path)
+            return None
+        except ConfigError as e:
+            self.show_config_error(str(e), path)
+            return None
+
+    def load_workspace_setting_configs(self):
+        """These may be used a lot if specified, so cache specially."""
         for root in self.get_roots():
-            try:
-                path = self.get_config_file(root)
-                config = get_config(path, root)
+            path = self.get_config_file(root)
+            if path is None:
+                continue
+            config = self.load_config(path, root)
+            if config is None:
+                continue
+            self.workspace_configs[root] = config
+
+    def _get_config(self, path: Path) -> Config:
+        workspace_root = self.get_root(path)
+
+        # First, check for configs specified in the LSP settings.
+        # If not in a workspace, our only shot is to use a global config. Otherwise, we
+        # bail (no searching, since the LSP only searches up to the workspace root).
+        if workspace_root is None:
+            global_file = self.global_settings.config_file
+            if global_file is not None:
+                config = self.load_config(global_file, path.parent)
                 if config is not None:
-                    self.configs[root] = config
-            except ConfigError as e:
-                self.show_message(f"Error loading config file: {e}")
+                    return config
+            return _DEFAULT_CONFIG
 
-        # If a global config file exists, we apply it to any file not under a workspace
-        # folder.
-        global_path = self.global_settings.config_file
-        if global_path is not None:
+        # If file is in a workspace, and we've got a workspace config configured, use
+        # that (this logic also handles global configs, since these are still
+        # instantiated once per workspace to resolve relative paths).
+        if workspace_root is not None and workspace_root in self.workspace_configs:
+            return self.workspace_configs[workspace_root]
+
+        # Otherwise, walk upwards until root.
+        # path is a file, which is a sneaky trick to guarantee we always run the first
+        # iteration. It becomes a directory after the first statement in the loop.
+        while path != workspace_root:
+            path = path.parent
             try:
-                config = get_config(global_path, global_path.parent)
-                self.global_config = config
+                config = load_config_at(path)
             except ConfigError as e:
-                self.show_message(f"Error loading config file: {e}")
+                self.show_config_error(str(e), path)
+                return _DEFAULT_CONFIG
 
-    def get_config(self, path: Path, root: Optional[Path]) -> Config:
-        if root in self.configs:
-            return self.configs[root].get_for_path(path)
-        if self.global_config is not None:
-            return self.global_config.get_for_path(path)
-        return Config()
+            if config is not None:
+                return config
 
-    def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
+        return _DEFAULT_CONFIG
+
+    def get_config(self, path: Path) -> Config:
+        """Return config object for a given path.
+
+        If no config has already been loaded for root (either by calling this function
+        or load_configs), this function will search for and load a config file if found.
+        """
+        if path in self.source_configs:
+            return self.source_configs[path]
+        config = self._get_config(path)
+        self.source_configs[path] = config
+
+        return config
+
+    def _compute_diagnostics(self, document: TextDocument) -> list[lsp.Diagnostic]:
         path = Path(document.path)
-        root = self.get_root(path)
-        config = self.get_config(path, root)
-
-        if root is None:
-            root = path.parent
-
+        config = self.get_config(path)
         is_excluded = utils.make_exclude_filter(config.exclude)
-        if is_excluded(path, root):
+        if is_excluded(path):
             return []
 
-        return lint(document.source, config, path)
+        return lint(document.source, config, self.plugin_manager, path)
 
     def compute_diagnostics(self, document: TextDocument):
         # `None` sentinel ensures that `diagnostics` gets updated if the URI is not
@@ -189,11 +247,10 @@ class TclspServer(LanguageServer):
         self,
         document: TextDocument,
         options: lsp.FormattingOptions,
-        range: Optional[Tuple[int, int]] = None,
+        range: Optional[tuple[int, int]] = None,
     ):
         path = Path(document.path)
-        root = self.get_root(path)
-        config = self.get_config(path, root)
+        config = self.get_config(path)
 
         parser = Parser()
 
@@ -205,9 +262,19 @@ class TclspServer(LanguageServer):
         formatter = Formatter(
             FormatterOpts(
                 indent=indent,
-                spaces_in_braces=config.style_spaces_in_braces,
+                indent_mixed_tab_size=config.get_indent_mixed_tab_size(),
+                spaces_in_braces=(
+                    config.style_spaces_in_braces == SpacesInBraces.ALWAYS
+                    or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
+                ),
+                balanced_spaces_in_braces=(
+                    config.style_spaces_in_braces == SpacesInBraces.BALANCED_NO
+                    or config.style_spaces_in_braces == SpacesInBraces.BALANCED_YES
+                ),
                 max_blank_lines=config.style_max_blank_lines,
                 indent_namespace_eval=config.style_indent_namespace_eval,
+                emacs=False,
+                debug_whitespace=False,
             )
         )
 
@@ -230,13 +297,30 @@ def did_open(ls: TclspServer, params: lsp.DidOpenTextDocumentParams):
 
 
 @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
-def did_change(ls: TclspServer, params: lsp.DidOpenTextDocumentParams):
+def did_change(ls: TclspServer, params: lsp.DidChangeTextDocumentParams):
     """Parse each document when it is changed"""
     logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_CHANGE, params)
     doc = ls.workspace.get_text_document(params.text_document.uri)
     ls.compute_diagnostics(doc)
 
 
+@server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
+def did_close(ls: TclspServer, params: lsp.DidCloseTextDocumentParams):
+    """Free up resources when a document is closed."""
+    logging.debug("Received %s: %s", lsp.TEXT_DOCUMENT_DID_CLOSE, params)
+    doc = ls.workspace.get_text_document(params.text_document.uri)
+
+    try:
+        del ls.diagnostics[doc.uri]
+    except KeyError:
+        pass
+
+    try:
+        del ls.source_configs[Path(doc.path)]
+    except KeyError:
+        pass
+
+
 @server.feature(
     lsp.TEXT_DOCUMENT_DIAGNOSTIC,
     lsp.DiagnosticOptions(
@@ -275,7 +359,11 @@ def change_watched_files(ls: TclspServer, params: lsp.DidChangeWatchedFilesParam
     # Clear diagnostics cache so they get recalculated when requested
     ls.diagnostics = {}
 
-    ls.load_configs()
+    # Config files changed, clear the many caches!
+    ls.config_files = {}
+    ls.source_configs = {}
+    ls.invalid_configs = set()
+
     if ls.client_supports_refresh:
         ls.lsp.send_request(lsp.WORKSPACE_DIAGNOSTIC_REFRESH, None)
 
@@ -369,7 +457,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
     capabilities = ls.client_capabilities.workspace
 
     try:
-        ls.client_supports_refresh = (
+        ls.client_supports_refresh = bool(
             capabilities.diagnostics.refresh_support  # type: ignore[union-attr]
         )
     except AttributeError:
@@ -391,7 +479,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
         for settings in (ls.global_settings, *ls.workspace_settings.values()):
             if settings.config_file is not None:
                 watchers.append(
-                    lsp.FileSystemWatcher(glob_pattern=settings.config_file)
+                    lsp.FileSystemWatcher(glob_pattern=str(settings.config_file))
                 )
 
         ls.register_capability(
@@ -408,7 +496,7 @@ def init(ls: TclspServer, params: lsp.InitializeParams):
             )
         )
 
-    ls.load_configs()
+    ls.load_workspace_setting_configs()
 
 
 def main():
diff --git a/server/libs/tclint/cli/utils.py b/server/libs/tclint/cli/utils.py
index a9c1e20..98539a3 100644
--- a/server/libs/tclint/cli/utils.py
+++ b/server/libs/tclint/cli/utils.py
@@ -1,11 +1,14 @@
 import codecs
 import os
-import pathlib
 import re
-from typing import List, Optional
+from collections import defaultdict
+from pathlib import Path
+from typing import Callable
 
 import pathspec
 
+from tclint.config import ExcludePattern
+
 
 def register_codec_warning(name):
     def replace_with_warning_handler(e):
@@ -16,73 +19,38 @@ def register_codec_warning(name):
     codecs.register_error(name, replace_with_warning_handler)
 
 
-def make_exclude_filter(exclude_patterns: List[str]):
-    exclude_patterns = [
-        re.sub(r"^\s*#", r"\#", pattern) for pattern in exclude_patterns
-    ]
-    exclude_spec = pathspec.PathSpec.from_lines("gitwildmatch", exclude_patterns)
+def make_exclude_filter(
+    exclude_patterns: list[ExcludePattern],
+) -> Callable[[Path], bool]:
+    # Transform patterns into a data structure keyed on root.
+    patterns_by_root = defaultdict(list)
+    for pattern, root in exclude_patterns:
+        # I think this is escaping #, which would otherwise be treated like a comment.
+        # Not 100% sure though, I originally wrote this a while ago.
+        pattern = re.sub(r"^\s*#", r"\#", pattern)
+        patterns_by_root[root.resolve()].append(pattern)
 
-    def is_excluded(path: pathlib.Path, root: pathlib.Path) -> bool:
+    compiled_patterns = {}
+    for root in patterns_by_root.keys():
+        patterns = patterns_by_root[root]
+        spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
+        compiled_patterns[root] = spec
+
+    def is_excluded(path: Path) -> bool:
         abspath = path.resolve()
-        root = root.resolve()
 
-        try:
-            relpath = pathlib.Path(os.path.relpath(abspath, start=root))
-        except ValueError:
-            # We get here if path and exclude_root are on different drives (on Windows).
-            # Things should still behave roughly as expected without using a relative
-            # path. See test_cli_utils.py::test_exclude_filter_windows for test cases.
-            relpath = abspath
+        for root, exclude_spec in compiled_patterns.items():
+            try:
+                relpath = Path(os.path.relpath(abspath, start=root))
+            except ValueError:
+                # We get here if path and exclude_root are on different drives (on
+                # Windows).Things should still behave roughly as expected without
+                # using a relative path. See
+                # test_cli_utils.py::test_exclude_filter_windows for test cases.
+                relpath = abspath
 
-        if exclude_spec.match_file(relpath):
-            return True
+            if exclude_spec.match_file(relpath):
+                return True
         return False
 
     return is_excluded
-
-
-def resolve_sources(
-    paths: List[pathlib.Path],
-    exclude_patterns: List[str],
-    exclude_root: pathlib.Path,
-    extensions: List[str],
-) -> List[Optional[pathlib.Path]]:
-    """Resolves paths passed via CLI to a list of filepaths to lint.
-
-    `paths` is a list of paths that may be files or directories. Files are
-    returned verbatim if they exist, and directories are recursively searched
-    for files that have an extension specified in `extensions`. Paths that match a
-    pattern in `exclude_patterns` are ignored (based on gitignore pattern
-    format, see https://git-scm.com/docs/gitignore#_pattern_format).
-
-    Raises FileNotFoundError if a supplied path does not exist.
-    """
-    extensions = [f".{ext}" if not ext.startswith(".") else ext for ext in extensions]
-    is_excluded = make_exclude_filter(exclude_patterns)
-
-    sources: List[Optional[pathlib.Path]] = []
-
-    for path in paths:
-        if str(path) == "-":
-            sources.append(None)
-            continue
-
-        if not path.exists():
-            raise FileNotFoundError(f"path {path} does not exist")
-
-        if is_excluded(path, exclude_root):
-            continue
-
-        if not path.is_dir():
-            sources.append(path)
-            continue
-
-        for dirpath, _, filenames in os.walk(path):
-            for name in filenames:
-                _, ext = os.path.splitext(name)
-                if ext.lower() in extensions:
-                    child = pathlib.Path(dirpath) / name
-                    if not is_excluded(child, exclude_root):
-                        sources.append(child)
-
-    return sources
diff --git a/server/libs/tclint/commands/__init__.py b/server/libs/tclint/commands/__init__.py
index b6c4222..b76f712 100644
--- a/server/libs/tclint/commands/__init__.py
+++ b/server/libs/tclint/commands/__init__.py
@@ -1,37 +1,4 @@
-import pathlib
-from typing import List, Dict, Union
-
-from tclint.commands import builtin as _builtin
-from tclint.commands.plugins import PluginManager
-
-# import to expose in package
+# Import to expose in package.
 from tclint.commands.checks import CommandArgError
 
-__all__ = ["CommandArgError", "validate_command_plugins", "get_commands"]
-
-
-def validate_command_plugins(plugins: List[str]) -> List[str]:
-    valid_plugins = []
-    for plugin in set(plugins):
-        if PluginManager.load(plugin) is not None:
-            valid_plugins.append(plugin)
-
-    return valid_plugins
-
-
-def get_commands(plugins: List[Union[str, pathlib.Path]]) -> Dict:
-    commands = {}
-    commands.update(_builtin.commands)
-
-    for plugin in plugins:
-        if isinstance(plugin, str):
-            plugin_commands = PluginManager.load(plugin)
-        elif isinstance(plugin, pathlib.Path):
-            plugin_commands = PluginManager.load_from_spec(plugin)
-        else:
-            raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}")
-
-        if plugin_commands is not None:
-            commands.update(plugin_commands)
-
-    return commands
+__all__ = ["CommandArgError"]
diff --git a/server/libs/tclint/commands/builtin.py b/server/libs/tclint/commands/builtin.py
index 44a1691..79824d7 100644
--- a/server/libs/tclint/commands/builtin.py
+++ b/server/libs/tclint/commands/builtin.py
@@ -32,13 +32,9 @@ these would be helpful for your use case, please file an issue.
   - https://www.tcl.tk/man/tcl/TclCmd/mathop.html
 """
 
-from tclint.commands.checks import (
-    CommandArgError,
-    check_count,
-    eval,
-)
+from tclint.commands.checks import CommandArgError, check_arg_spec, check_count, eval
 from tclint.commands.schema import commands_schema
-from tclint.syntax_tree import BareWord
+from tclint.syntax_tree import BareWord, Node
 
 
 def _check_code(arg):
@@ -77,7 +73,7 @@ def _after(args, parser):
 def _after_cancel(args, parser):
     """after id|(script...)"""
     # ref: https://www.tcl.tk/man/tcl/TclCmd/after.html
-    check_count("after cancel", 1, None)
+    check_count("after cancel", 1, None)(args, parser)
 
     # TODO: raise warning about not checking code
 
@@ -181,20 +177,6 @@ _array = {
 }
 
 
-def _catch(args, parser):
-    """catch script [resultVarName] [optionsVarName]"""
-    if len(args) < 1:
-        raise CommandArgError(
-            f"not enough args to catch: got {len(args)}, expected at least 1"
-        )
-    if len(args) > 3:
-        raise CommandArgError(
-            f"too many args to catch: got {len(args)}, expected no more than 3"
-        )
-
-    return [parser.parse_script(args[0])] + args[1:]
-
-
 _chan = {
     "subcommands": {
         "blocked": {
@@ -350,43 +332,37 @@ def _dict_filter(args, parser):
 
 def _dict_map_for(cmd):
     def check(args, parser):
-        if len(args) != 3:
-            raise CommandArgError(
-                f"wrong # of args to '{cmd}': got {len(args)}, expected 3"
-            )
-
+        spec = {
+            "positionals": [
+                {"name": "keyValueList", "value": {"type": "any"}, "required": True},
+                {"name": "dictionaryValue", "value": {"type": "any"}, "required": True},
+                {"name": "body", "value": {"type": "script"}, "required": True},
+            ],
+            "switches": {},
+        }
         # TODO: might be worth checking that arg[0] is a pair?
-
-        return args[0:2] + [parser.parse_script(args[2])]
+        return check_arg_spec(cmd, args, parser, spec)
 
     return check
 
 
 def _dict_update(args, parser):
-    # ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M25
+    """dict update dictionaryVariable key varName ?key varName ...? body
 
-    if len(args) < 4:
-        raise CommandArgError(
-            f"not enough args to 'dict update': got {len(args)}, expected at least 4"
-        )
-
-    if len(args) % 2 != 0:
-        raise CommandArgError(
-            "invalid # of args to 'dict update': expected an even number"
-        )
-
-    return args[0:-1] + [parser.parse_script(args[-1])]
-
-
-def _dict_with(args, parser):
-    # ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M27
-
-    if len(args) < 2:
-        raise CommandArgError(
-            f"not enough args to 'dict with': got {len(args)}, expected at least 2"
-        )
-
-    return args[0:-1] + [parser.parse_script(args[-1])]
+    ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/dict.htm#M25
+    """
+    spec = {
+        "positionals": [
+            {"name": "dictionaryVariable", "value": {"type": "any"}, "required": True},
+            {"name": "key", "value": {"type": "any"}, "required": True},
+            {"name": "varName", "value": {"type": "any"}, "required": True},
+            {"name": "key varName", "value": {"type": "variadic"}, "required": False},
+            {"name": "body", "value": {"type": "script"}, "required": True},
+        ],
+        "switches": {},
+    }
+    # TODO: Check that number of variadic words is even.
+    return check_arg_spec("dict update", args, parser, spec)
 
 
 def _eval(args, parser):
@@ -434,53 +410,100 @@ def _fileevent(args, parser):
     )
 
 
-def _for(args, parser):
-    # ref: https://www.tcl.tk/man/tcl/TclCmd/for.html
-    if len(args) != 4:
-        raise CommandArgError(f"wrong # of args to for: got {len(args)}, expected 4")
+def foreach(args, parser):
+    """
+    foreach varname list ?varlist list ...? body
 
-    return [
-        parser.parse_script(args[0]),
-        parser.parse_expression(args[1]),
-        parser.parse_script(args[2]),
-        parser.parse_script(args[3]),
-    ]
+    ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/foreach.htm
+    """
+    spec = {
+        "positionals": [
+            {"name": "varname", "value": {"type": "any"}, "required": True},
+            {"name": "list", "value": {"type": "any"}, "required": True},
+            {"name": "varlist list", "value": {"type": "variadic"}, "required": False},
+            {"name": "body", "value": {"type": "script"}, "required": True},
+        ],
+        "switches": {},
+    }
+    # TODO: check that "varlist list" comes in pairs.
+    return check_arg_spec("foreach", args, parser, spec)
 
 
-def _foreach(args, parser):
-    # ref: https://www.tcl.tk/man/tcl/TclCmd/foreach.html
-    if len(args) < 3:
-        raise CommandArgError(
-            f"insufficient args to foreach: got {len(args)}, expected at least 3"
-        )
+def _if(args, parser) -> list[Node]:
+    # ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/if.htm
 
-    # last argument is script body
-    return args[0:-1] + [parser.parse_script(args[-1])]
-
-
-def _if(args, parser):
-    # ref: https://www.tcl.tk/man/tcl/TclCmd/if.html
-    # TODO: make arg checking strict
-
-    new_args = []
+    new_args: list[Node] = []
 
+    # Parse if condition.
+    if len(new_args) == len(args):
+        raise CommandArgError("Expected condition argument in 'if'")
     new_args.append(parser.parse_expression(args[0]))
 
-    while len(new_args) < len(args):
-        arg = args[len(new_args)]
-
-        if arg.contents == "then" or arg.contents == "else":
-            new_args.append(arg)
-            continue
-        if arg.contents == "elseif":
-            new_args.append(arg)
-            new_args.append(parser.parse_expression(args[len(new_args)]))
-            continue
-
-        arg = parser.parse_script(arg)
+    # Parse optional noise word then.
+    if len(new_args) == len(args):
+        raise CommandArgError("Expected then or body argument in 'if'")
+    arg = args[len(new_args)]
+    if arg.contents == "then":
         new_args.append(arg)
 
-    return new_args
+    # Parse if body.
+    if len(new_args) == len(args):
+        raise CommandArgError("Expected body argument in 'if'")
+    new_args.append(parser.parse_script(args[len(new_args)]))
+
+    # Parse elseif.
+    while (
+        len(new_args) < len(args)
+        and (arg := args[len(new_args)])
+        and arg.contents == "elseif"
+    ):
+        new_args.append(arg)
+
+        # Parse elseif condition.
+        if len(new_args) == len(args):
+            raise CommandArgError(
+                "Expected condition argument in 'elseif' part of 'if'"
+            )
+        new_args.append(parser.parse_expression(args[len(new_args)]))
+
+        # Parse optional noise word then.
+        if len(new_args) == len(args):
+            raise CommandArgError(
+                "Expected then or body argument in 'elseif' part of 'if'"
+            )
+        arg = args[len(new_args)]
+        if arg.contents == "then":
+            new_args.append(arg)
+
+        # Parse elseif body.
+        if len(new_args) == len(args):
+            raise CommandArgError("Expected body argument in 'elseif' part of 'if'")
+        new_args.append(parser.parse_script(args[len(new_args)]))
+
+    if len(new_args) == len(args):
+        # No else part, we're done.
+        return new_args
+
+    # Parse optional noise word else.
+    arg = args[len(new_args)]
+    if arg.contents == "else":
+        new_args.append(arg)
+
+    # Parse else body.
+    if len(new_args) == len(args):
+        raise CommandArgError("Expected body argument to 'else' part of 'if'")
+    new_args.append(parser.parse_script(args[len(new_args)]))
+
+    if len(new_args) == len(args):
+        # Else part parsed, we're done.
+        return new_args
+
+    # Handle superfluous args.
+    arg = args[len(new_args)]
+    if arg.contents is None or not arg.contents:
+        raise CommandArgError("Argument after complete 'if'")
+    else:
+        raise CommandArgError(f"Argument after complete 'if': {arg.contents}")
 
 
 def _interp_eval(args, parser):
@@ -492,13 +515,22 @@ def _interp_eval(args, parser):
 
 
 def _lmap(args, parser):
-    # ref: https://www.tcl.tk/man/tcl/TclCmd/lmap.html
-    if len(args) < 3:
-        raise CommandArgError(
-            f"not enough args to lmap: got {len(args)}, expected at least 3"
-        )
+    """
+    lmap varlist1 list1 ?varlist2 list2 ...? body
 
-    return args[:-1] + [parser.parse_script(args[-1])]
+    ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/lmap.htm
+    """
+    spec = {
+        "positionals": [
+            {"name": "varlist1", "value": {"type": "any"}, "required": True},
+            {"name": "list1", "value": {"type": "any"}, "required": True},
+            {"name": "varlist list", "value": {"type": "variadic"}, "required": False},
+            {"name": "body", "value": {"type": "script"}, "required": True},
+        ],
+        "switches": {},
+    }
+    # TODO: Check that number of variadic words is even.
+    return check_arg_spec("lmap", args, parser, spec)
 
 
 def _namespace_code(args, parser):
@@ -656,6 +688,9 @@ def _switch(args, parser):
     for i, node in enumerate(pattern_and_commands):
         if i % 2 == 0:
             parsed_patterns_and_commands.append(node)
+        elif node.contents == "-":
+            # Detect passthrough.
+            parsed_patterns_and_commands.append(node)
         else:
             parsed_patterns_and_commands.append(parser.parse_script(node))
 
@@ -795,16 +830,6 @@ def _try(args, parser):
     return new_args
 
 
-def _while(args, parser):
-    if len(args) != 2:
-        raise CommandArgError(f"wrong # of args to while: got {len(args)}, expected 2")
-
-    return [
-        parser.parse_expression(args[0]),
-        parser.parse_script(args[1]),
-    ]
-
-
 commands = commands_schema({
     "after": {
         "subcommands": {
@@ -828,14 +853,63 @@ commands = commands_schema({
     "array": _array,
     "binary": {
         "subcommands": {
-            "decode": check_count("binary decode", 2, None),
-            "encode": check_count("binary encode", 2, None),
-            "format": check_count("binary format", 1, None),
-            "scan": check_count("binary scan", 2, None),
+            "decode": {
+                "positionals": [
+                    {"name": "format", "value": {"type": "any"}, "required": True},
+                    {
+                        "name": "options",
+                        "value": {"type": "variadic"},
+                        "required": False,
+                    },
+                    {"name": "data", "value": {"type": "any"}, "required": True},
+                ],
+            },
+            "encode": {
+                "positionals": [
+                    {"name": "format", "value": {"type": "any"}, "required": True},
+                    {
+                        "name": "options",
+                        "value": {"type": "variadic"},
+                        "required": False,
+                    },
+                    {"name": "data", "value": {"type": "any"}, "required": True},
+                ],
+            },
+            "format": {
+                "positionals": [
+                    {
+                        "name": "formatString",
+                        "value": {"type": "any"},
+                        "required": True,
+                    },
+                    {"name": "args", "value": {"type": "variadic"}, "required": False},
+                ],
+            },
+            "scan": {
+                "positionals": [
+                    {"name": "string", "value": {"type": "any"}, "required": True},
+                    {
+                        "name": "formatString",
+                        "value": {"type": "any"},
+                        "required": True,
+                    },
+                    {
+                        "name": "varName",
+                        "value": {"type": "variadic"},
+                        "required": False,
+                    },
+                ],
+            },
         },
     },
-    "break": check_count("break", 0, 0),
-    "catch": _catch,
+    "break": {},
+    "catch": {
+        "positionals": [
+            {"name": "script", "value": {"type": "script"}, "required": True},
+            {"name": "resultVarName", "value": {"type": "any"}, "required": False},
+            {"name": "optionsVarName", "value": {"type": "any"}, "required": False},
+        ]
+    },
     "cd": {
         "positionals": [
             {"name": "dirName", "value": {"type": "any"}, "required": False}
@@ -884,7 +958,17 @@ commands = commands_schema({
             "unset": check_count("dict unset", 2, None),
             "update": _dict_update,
             "values": check_count("dict values", 1, 2),
-            "with": _dict_with,
+            "with": {
+                "positionals": [
+                    {
+                        "name": "dictionaryVariable",
+                        "value": {"type": "any"},
+                        "required": True,
+                    },
+                    {"name": "key", "value": {"type": "variadic"}, "required": False},
+                    {"name": "script", "value": {"type": "script"}, "required": True},
+                ]
+            },
         },
     },
     "encoding": {
@@ -909,8 +993,15 @@ commands = commands_schema({
     "file": check_count("file", 1, None),
     "fileevent": _fileevent,
     "flush": check_count("flush", 1, 1),
-    "for": _for,
-    "foreach": _foreach,
+    "for": {
+        "positionals": [
+            {"name": "start", "value": {"type": "script"}, "required": True},
+            {"name": "test", "value": {"type": "expression"}, "required": True},
+            {"name": "next", "value": {"type": "script"}, "required": True},
+            {"name": "body", "value": {"type": "script"}, "required": True},
+        ],
+    },
+    "foreach": foreach,
     "format": check_count("format", 1, None),
     "gets": check_count("gets", 1, 2),
     "glob": check_count("glob"),
@@ -974,8 +1065,28 @@ commands = commands_schema({
             "inscope": _namespace_inscope,
             "origin": check_count("namespace origin", 1, 1),
             "parent": check_count("namespace parent", 0, 1),
+            "path": {
+                "positionals": [
+                    {
+                        "name": "namespaceList",
+                        "value": {"type": "any"},
+                        "required": False,
+                    },
+                ]
+            },
             "qualifiers": check_count("namespace qualifiers", 1, 1),
             "tail": check_count("namespace tail", 1, 1),
+            "unknown": {
+                "positionals": [
+                    {"name": "script", "value": {"type": "script"}, "required": False}
+                ]
+            },
+            "upvar": {
+                "positionals": [
+                    {"name": "namespace", "value": {"type": "any"}, "required": True},
+                    {"name": "var", "value": {"type": "variadic"}, "required": False},
+                ]
+            },
             "which": check_count("namespace which", 1, 2),
             "ensemble": {
                 "subcommands": {
@@ -1028,7 +1139,7 @@ commands = commands_schema({
     "source": check_count("source", 1, 3),
     "split": check_count("split", 1, 2),
     # TODO: check subcommands
-    "string": check_count("string", 2, None),
+    "string": check_count("string", 1, None),
     "subst": check_count("subst", 1, 4),
     "switch": _switch,
     "tailcall": check_count("tailcall", 1, None),
@@ -1061,7 +1172,12 @@ commands = commands_schema({
     "upvar": check_count("upvar", 2, None),
     "variable": check_count("variable", 1, None),
     "vwait": check_count("vwait", 1, 1),
-    "while": _while,
+    "while": {
+        "positionals": [
+            {"name": "test", "value": {"type": "expression"}, "required": True},
+            {"name": "body", "value": {"type": "script"}, "required": True},
+        ],
+    },
     "yield": {
         "positionals": [
             {"name": "value", "value": {"type": "any"}, "required": False},
@@ -1074,5 +1190,5 @@ commands = commands_schema({
         ]
     },
     # TODO: check subcommands
-    "zlib": check_count("zlib", 3, None),
+    "zlib": check_count("zlib", 2, None),
 })
diff --git a/server/libs/tclint/commands/checks.py b/server/libs/tclint/commands/checks.py
index 911f58e..d2b61ba 100644
--- a/server/libs/tclint/commands/checks.py
+++ b/server/libs/tclint/commands/checks.py
@@ -1,25 +1,45 @@
 """Helpers for checking command arguments."""
 
-from collections.abc import Callable
-from typing import List, Optional, Union
+from __future__ import annotations
 
-from tclint.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Optional
+
+from tclint.syntax_tree import ArgExpansion, BareWord, BracedWord, Node, QuotedWord
+
+# This lets us use Parser in type annotations without introducing a cyclic dependency.
+if TYPE_CHECKING:
+    from tclint.parser import Parser
 
 
 class CommandArgError(Exception):
+    """Exception raised by command handlers to indicate invalid arguments."""
+
     pass
 
 
-def arg_count(args, parser):
-    # TODO: graceful handling of argsub going into things with recursive parsing.
-    # if the argsub happens to be "concrete", we can technically do the right
-    # thing (although this should probably be flagged as a readability issue...)
-    # otherwise, we should flag that the non-concrete argsub is not okay for
-    # these cases. however, I think its not okay-ness doesn't need to be absolute, e.g.
-    # I think we could allow:
-    #
-    #  catch {puts "my script"} {*}$catchopts
-    #
+def arg_count(args: list[Node], parser: Parser) -> tuple[int, bool]:
+    """Returns the number of arguments in args, taking {*} into account.
+
+    If an argument list contains an argument expansion operator that cannot be
+    statically expanded, the second return value is True, and the count is the minimum
+    possible number of arguments. Otherwise, the return value is False and the count
+    reflects the exact number of arguments.
+
+    This function should always be used for validating argument count, rather than
+    relying on `len(args)`.
+    """
+
+    # TODO: Replace this with or add a similar `expand_args` function that returns an
+    # expanded argument list. One tricky thing is we want to handle cases like:
+    # - foreach {*}$iters { ...body... }
+    # - catch {puts "my script"} {*}$catchopts
+    # With something like a list structure we can either forward or reverse index to
+    # still parse the body even with an unexpanded {*}.
+
+    # TODO: Add a violation that flags `{*}{a b c}` for rewriting as `a b c`. A future
+    # version of tclfmt that allows rewrites that break the syntax tree could do this
+    # automatically.
 
     arg_count = 0
     has_arg_expansion = False
@@ -28,14 +48,14 @@ def arg_count(args, parser):
             if arg.contents is None:
                 has_arg_expansion = True
                 continue
-            arg_count += len(parser.parse_list(arg.contents))
+            arg_count += len(parser.parse_list(arg).children)
         else:
             arg_count += 1
 
     return arg_count, has_arg_expansion
 
 
-def check_count(command, min=None, max=None, args_name="args"):
+def check_count(command, min=None, max=None):
     def check(args, parser):
         if min is None and max is None:
             return None
@@ -44,19 +64,17 @@ def check_count(command, min=None, max=None, args_name="args"):
 
         if not has_arg_expansion and min == max and count != min:
             raise CommandArgError(
-                f"wrong # of {args_name} for {command}: got {count}, expected {min}"
+                f"wrong # of args for {command}: got {count}, expected {min}"
             )
 
         if not has_arg_expansion and min is not None and count < min:
             raise CommandArgError(
-                f"not enough {args_name} for {command}: got {count}, expected at least"
-                f" {min}"
+                f"not enough args for {command}: got {count}, expected at least {min}"
             )
 
         if max is not None and count > max:
             raise CommandArgError(
-                f"too many {args_name} for {command}: got {count}, expected no more"
-                f" than {max}"
+                f"too many args for {command}: got {count}, expected no more than {max}"
             )
 
         return None
@@ -64,7 +82,7 @@ def check_count(command, min=None, max=None, args_name="args"):
     return check
 
 
-def eval(args, parser, command):
+def eval(args: list[Node], parser: Parser, command: str) -> list[Node]:
     if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args):
         # Slightly odd restriction, but our syntax tree doesn't have a great way
         # to handle this case. We require each command argument to correspond to
@@ -106,15 +124,18 @@ def eval(args, parser, command):
 
         prev_arg_end_pos = arg.end_pos
 
-    script = parser.parse(eval_script, pos=(args[0].pos))
+    script = parser.parse(eval_script, pos=(args[0].contents_pos))
     script.end_pos = args[-1].end_pos
 
     return [script]
 
 
 def check_command(
-    command: str, args: List[Node], parser, command_spec: Union[Callable, dict, None]
-) -> Optional[List[Node]]:
+    command: str,
+    args: list[Node],
+    parser: Parser,
+    command_spec: Callable | dict | None,
+) -> Optional[list[Node]]:
     if command_spec is None:
         return None
 
@@ -124,42 +145,97 @@ def check_command(
     return command_spec(args, parser)
 
 
+def _positional_has_type(type: str, arg_spec: dict, indices: list[int]) -> bool:
+    return any([arg_spec["positionals"][i]["value"]["type"] == type for i in indices])
+
+
 def check_arg_spec(
-    command: str, args: List[Node], parser, arg_spec: dict
-) -> Optional[List[Node]]:
+    command: str, args: list[Node], parser: Parser, arg_spec: dict
+) -> Optional[list[Node]]:
     if "subcommands" in arg_spec:
-        subcommands = arg_spec["subcommands"]
-        try:
-            subcommand = args[0].contents
-        except IndexError:
-            subcommand = None
-
-        if subcommand in subcommands:
-            new_args = check_command(
-                f"{command} {subcommand}", args[1:], parser, subcommands[subcommand]
-            )
-            if new_args is None:
-                return new_args
-            return args[0:1] + new_args
-
-        if "" in subcommands:
-            return check_command(command, args, parser, subcommands[""])
-
-        if subcommand is not None:
-            msg = f"invalid subcommand for {command}: got {subcommand}"
-        else:
-            msg = f"no subcommand provided for {command}"
-
-        raise CommandArgError(f"{msg}, expected one of {', '.join(subcommands.keys())}")
+        return dispatch_subcommands(command, args, parser, arg_spec["subcommands"])
 
     switches = arg_spec["switches"]
-    args_allowed = set(switches)
+    mapped, positional_args = map_switches(args, switches, command)
+
     args_required = {switch for switch in switches if switches[switch]["required"]}
+    missing_required = args_required.difference(mapped)
+
+    if len(missing_required) > 1:
+        raise CommandArgError(
+            f"missing required arguments for {command}: {', '.join(missing_required)}"
+        )
+    elif len(missing_required) == 1:
+        raise CommandArgError(
+            f"missing required argument for {command}: {missing_required.pop()}"
+        )
+
+    positionals = [args[i] for i in positional_args]
+    mapping = map_positionals(positionals, arg_spec["positionals"], command)
+    args = list(args)
+    for arg_i, map_to_spec in zip(positional_args, mapping):
+        if _positional_has_type("script", arg_spec, map_to_spec):
+            args[arg_i] = parser.parse_script(args[arg_i])
+        elif _positional_has_type("expression", arg_spec, map_to_spec):
+            args[arg_i] = parser.parse_expression(args[arg_i])
+
+    return args
+
+
+def dispatch_subcommands(
+    command: str, args: list[Node], parser: Parser, spec: dict
+) -> Optional[list[Node]]:
+    try:
+        subcommand = args[0].contents
+    except IndexError:
+        subcommand = None
+
+    if subcommand in spec:
+        new_args = check_command(
+            f"{command} {subcommand}", args[1:], parser, spec[subcommand]
+        )
+        if new_args is None:
+            return new_args
+        return args[0:1] + new_args
+
+    if "" in spec:
+        return check_command(command, args, parser, spec[""])
+
+    if subcommand is not None:
+        msg = f"invalid subcommand for {command}: got {subcommand}"
+    else:
+        msg = f"no subcommand provided for {command}"
+
+    raise CommandArgError(f"{msg}, expected one of {', '.join(spec.keys())}")
+
+
+def map_switches(
+    args: list[Node], switches: dict, command_name: str
+) -> tuple[set[str], list[int]]:
+    """Separates switch arguments from positional arguments in a command's argument
+    list.
+
+    `switches` represents the "switches" entry of the spec for the given command. The
+    return value is a tuple of (mapped_switches, positional_indices). mapped_switches is
+    a set of switch names that were found in args. positional_indices is a list of
+    indices into args for arguments that are not switches.
+
+    If the switches found do not map correctly to the spec, this function raises
+    CommandArgError.
+
+    The `command_name` argument is used to generate descriptive error messages.
+    """
+    mapped: set[str] = set()
+
+    if not switches:
+        return mapped, list(range(len(args)))
+
     positional_args = []
 
-    args = list(args)
-    while len(args) > 0:
-        arg = args.pop(0)
+    arg_i = 0
+    while arg_i < len(args):
+        arg = args[arg_i]
+        arg_i += 1
 
         # To facilitate better error messages, we expect that switches are always
         # specified as BareWords that start with "-" or ">". This lets us throw an
@@ -170,71 +246,146 @@ def check_arg_spec(
         # any switches should be BareWords.
         contents = arg.contents
         if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}):
-            positional_args.append(arg)
+            positional_args.append(arg_i - 1)
             continue
 
-        # TODO check required arguments
-        if contents in args_allowed:
+        if contents in switches:
+            if contents in mapped and not switches[contents]["repeated"]:
+                raise CommandArgError(
+                    f"duplicate argument for {command_name}: {contents}"
+                )
             if switches[contents]["value"]:
-                try:
-                    args.pop(0)
-                except IndexError:
+                arg_i += 1
+                if arg_i > len(args):
                     raise CommandArgError(
-                        f"invalid arguments for {command}: expected value after"
+                        f"invalid arguments for {command_name}: expected value after"
                         f" {contents}"
                     )
-            if not switches[contents]["repeated"]:
-                args_allowed.remove(contents)
-            if contents in args_required:
-                args_required.remove(contents)
-        elif contents in arg_spec:
-            raise CommandArgError(f"duplicate argument for {command}: {contents}")
-        else:
-            prefix_matches = []
-            for switch in switches:
-                if switch.startswith(contents):
-                    prefix_matches.append(switch)
+            mapped.add(contents)
+            continue
 
-            if len(prefix_matches) == 1:
+        prefix_matches = []
+        for switch in switches:
+            if switch.startswith(contents):
+                prefix_matches.append(switch)
+
+        if len(prefix_matches) == 1:
+            raise CommandArgError(
+                f"shortened argument for {command_name}: expand {contents} to"
+                f" {prefix_matches[0]}"
+            )
+
+        if len(prefix_matches) > 1:
+            raise CommandArgError(
+                f"ambiguous argument for {command_name}: {contents} could be any of"
+                f" {', '.join(prefix_matches)}"
+            )
+
+        raise CommandArgError(f"unrecognized argument for {command_name}: {contents}")
+
+    return mapped, positional_args
+
+
+def map_positionals(
+    args: list[Node], spec: list[dict], command_name: str
+) -> list[list[int]]:
+    """Maps a list of nodes representing positional command arguments to the specific
+    positional arguments of a command. spec represents the "positionals" entry of the
+    spec for the given command.
+
+    The return value is a list whose entries correspond one-to-one to the entries in
+    `args`. Each item in the return value is a list of indices into `spec`, indicating
+    which argument(s) in the spec the corresponding argument maps to.
+
+    A given index into `spec` may appear multiple times in the list (e.g. if it's a
+    variadic argument), and a list may contain more than one index for the mapping of an
+    arg expansion.
+
+    If the arguments do not map correctly to the spec, this function raises
+    CommandArgError.
+
+    Given a set of args and a spec, there may be multiple possible mappings. This
+    function will return some mapping if one exists.
+
+    The `command_name` argument is used to generate descriptive error messages.
+    """
+
+    if len(args) == len(spec):
+        # Self explanatory: a 1:1 match in argument count should be a legal mapping.
+        return [[i] for i in range(len(args))]
+
+    mapping: list[list[int]] = []
+    i = 0
+    if len(args) > len(spec):
+        # If there are more arguments than specified positionals, we map every argument
+        # greedily and assign the extra # of arguments to the first variadic we find.
+        extra = len(args) - len(spec)
+        for arg in args:
+            if i >= len(spec):
+                # We never found a variadic to save us, raise an error.
                 raise CommandArgError(
-                    f"shortened argument for {command}: expand {contents} to"
-                    f" {prefix_matches[0]}"
+                    f"too many arguments for {command_name}: got {len(args)}, expected"
+                    f" no more than {len(spec)}"
                 )
 
-            if len(prefix_matches) > 1:
-                raise CommandArgError(
-                    f"ambiguous argument for {command}: {contents} could be any of"
-                    f" {', '.join(prefix_matches)}"
-                )
+            mapping.append([i])
+            if spec[i]["value"]["type"] == "variadic" and extra > 0:
+                extra -= 1
+            else:
+                i += 1
 
-            raise CommandArgError(f"unrecognized argument for {command}: {contents}")
+        return mapping
 
-    if len(args_required) > 1:
-        raise CommandArgError(
-            f"missing required arguments for {command}: {', '.join(args_required)}"
-        )
-    elif len(args_required) == 1:
-        raise CommandArgError(
-            f"missing required argument for {command}: {args_required.pop()}"
-        )
+    required = []
+    for argspec in spec:
+        if argspec["required"]:
+            required.append(argspec["name"])
+    num_required = len(required)
 
-    min_positionals = 0
-    max_positionals: Optional[int] = 0
-    for positional in arg_spec["positionals"]:
-        if positional["value"]["type"] == "variadic":
-            max_positionals = None
+    if len(args) < num_required:
+        # If there are fewer arguments than required positionals, we map only required
+        # arguments and expand the first arg expansion we find to account for what's
+        # missing.
+        missing = num_required - len(args)
+        for arg in args:
+            while not spec[i]["required"]:
+                i += 1
 
-        if positional["required"]:
-            min_positionals += 1
-        if max_positionals is not None:
-            max_positionals += 1
+            mapping.append([i])
+            i += 1
 
-    check = check_count(
-        command,
-        min=min_positionals,
-        max=max_positionals,
-        args_name="positional args",
-    )
-    check(positional_args, None)
+            if isinstance(arg, ArgExpansion):
+                # Map missing arguments.
+                while missing > 0:
+                    if spec[i]["required"]:
+                        mapping[-1] += [i]
+                        missing -= 1
+                    i += 1
 
-    return None
+        if missing > 0:
+            missing_names = ", ".join(required[-missing:])
+            raise CommandArgError(
+                f"missing required argument{'s' if missing > 1 else ''} for"
+                f" {command_name}: {missing_names}"
+            )
+
+        return mapping
+
+    optionals = len(args) - num_required
+    for arg in args:
+        # If our argument count falls somewhere in between the required and total
+        # specified numbers of positionals, we map all required arguments and map as
+        # many optionals as needed (as we find them).
+        if not spec[i]["required"] and optionals > 0:
+            mapping.append([i])
+            i += 1
+            optionals -= 1
+            continue
+
+        while not spec[i]["required"]:
+            i += 1
+
+        mapping.append([i])
+        i += 1
+
+    return mapping
diff --git a/server/libs/tclint/commands/plugins.py b/server/libs/tclint/commands/plugins.py
index 276a7d4..40efdb6 100644
--- a/server/libs/tclint/commands/plugins.py
+++ b/server/libs/tclint/commands/plugins.py
@@ -1,25 +1,31 @@
-from importlib_metadata import entry_points
 import json
 import pathlib
-from typing import Dict, Optional
+from collections.abc import Sequence
+from importlib.util import module_from_spec, spec_from_file_location
 from types import ModuleType
+from typing import Optional
 
 import voluptuous
+from importlib_metadata import EntryPoint, entry_points
 
-from tclint.commands.schema import schema as command_schema
+from tclint.commands import builtin as _builtin
+from tclint.commands import schema
 
 
-class _PluginManager:
-    def __init__(self):
-        self._loaded = {}
-        self._installed = {}
-        self._loaded_specs = {}
+class PluginManager:
+    def __init__(self, trust_uninstalled=False) -> None:
+        self._loaded: dict[str, Optional[dict]] = {}
+        self._installed: dict[str, EntryPoint] = {}
+        self._loaded_specs: dict[pathlib.Path, Optional[dict]] = {}
+        self._loaded_py: dict[pathlib.Path, Optional[dict]] = {}
         for plugin in entry_points(group="tclint.plugins"):
             if plugin.name in self._installed:
                 print(f"Warning: found duplicate definitions for plugin {plugin.name}")
             self._installed[plugin.name] = plugin
 
-    def load(self, name: str) -> Optional[Dict]:
+        self._trust_uninstalled = trust_uninstalled
+
+    def load(self, name: str) -> Optional[dict]:
         if name in self._loaded:
             return self._loaded[name]
 
@@ -27,7 +33,7 @@ class _PluginManager:
         self._loaded[name] = mod
         return mod
 
-    def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]:
+    def load_from_spec(self, path: pathlib.Path) -> Optional[dict]:
         if path in self._loaded_specs:
             return self._loaded_specs[path]
 
@@ -35,17 +41,21 @@ class _PluginManager:
         self._loaded_specs[path] = spec
         return spec
 
-    def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]:
+    def _load_from_spec(self, path: pathlib.Path) -> Optional[dict]:
         try:
             with open(path.expanduser(), "r") as f:
                 spec = json.load(f)
         except (FileNotFoundError, RuntimeError):
+            # expanduser() may raise RuntimeError
             print(f"Warning: command spec {path} not found, skipping...")
             return None
+        except (json.JSONDecodeError, UnicodeDecodeError) as e:
+            print(f"Warning: {path} contains invalid JSON: {e}, skipping...")
+            return None
 
         try:
             # Apply defaults and validate the spec.
-            spec = command_schema(spec)
+            spec = schema.schema(spec)
         except voluptuous.Invalid as e:
             print(f"Warning: invalid command spec {path}: {e}")
             return None
@@ -67,8 +77,7 @@ class _PluginManager:
 
         return module
 
-    def _load(self, name: str):
-        module = self.get_mod(name)
+    def _load_module(self, name, module):
         if module is None:
             print(f"Skipping requested plugin {name}")
             return None
@@ -77,10 +86,72 @@ class _PluginManager:
             print(f"Warning: skipping plugin {name} since it does not define commands")
             return None
 
-        return getattr(module, "commands")
+        spec = getattr(module, "commands")
+        try:
+            # Apply defaults and validate the spec.
+            spec = schema.commands_schema(spec)
+        except voluptuous.Invalid as e:
+            print(f"Warning: invalid plugin {name}: {e}")
+            return None
 
+        return spec
 
-# TODO: we'll probably want to construct this in the tclint entry point and pass
-# it around rather than using a singleton instance, but this made for an easier
-# refactor.
-PluginManager = _PluginManager()
+    def _load(self, name: str):
+        module = self.get_mod(name)
+        return self._load_module(name, module)
+
+    def load_from_py(self, path: pathlib.Path) -> Optional[dict]:
+        if path in self._loaded_py:
+            return self._loaded_py[path]
+
+        spec = self._load_from_py(path)
+        self._loaded_py[path] = spec
+        return spec
+
+    def _load_from_py(self, path: pathlib.Path) -> Optional[dict]:
+        mod = None
+        name = path.stem
+
+        # By default, reject paths to dynamic plugins. This restriction is designed to
+        # make it explicit when tclint is executing external code.
+        if not self._trust_uninstalled:
+            print(
+                f"Warning: skipping untrusted plugin {path}. If you trust the code at"
+                " this path, re-run with --trust-plugins to load the plugin"
+            )
+            return None
+
+        try:
+            spec = spec_from_file_location(name, path)
+            if spec is not None:
+                mod = module_from_spec(spec)
+                if spec.loader is not None:
+                    spec.loader.exec_module(mod)
+        except FileNotFoundError:
+            print(f"Warning: command spec {path} not found, skipping...")
+            return None
+        except Exception as e:
+            print(f"Warning: error loading plugin {path}: {e}")
+            return None
+
+        return self._load_module(name, mod)
+
+    def get_commands(self, plugins: Sequence[str | pathlib.Path]) -> dict:
+        commands = {}
+        commands.update(_builtin.commands)
+
+        for plugin in plugins:
+            if isinstance(plugin, str):
+                plugin_commands = self.load(plugin)
+            elif isinstance(plugin, pathlib.Path):
+                if plugin.suffix == ".py":
+                    plugin_commands = self.load_from_py(plugin)
+                else:
+                    plugin_commands = self.load_from_spec(plugin)
+            else:
+                raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}")
+
+            if plugin_commands is not None:
+                commands.update(plugin_commands)
+
+        return commands
diff --git a/server/libs/tclint/commands/schema.py b/server/libs/tclint/commands/schema.py
index 7662a10..c807f45 100644
--- a/server/libs/tclint/commands/schema.py
+++ b/server/libs/tclint/commands/schema.py
@@ -1,5 +1,6 @@
 from collections.abc import Callable
-from voluptuous import Schema, Optional, Or, Self
+
+from voluptuous import Optional, Or, Schema, Self
 
 # Need to define this as a Schema with required=True to ensure that this requirement
 # persists through the Or in the main schema definition.
@@ -9,7 +10,12 @@ _command_args = Schema(
             {
                 "name": str,
                 "required": bool,
-                "value": Or({"type": "any"}, {"type": "variadic"}),
+                "value": Or(
+                    {"type": "any"},
+                    {"type": "variadic"},
+                    {"type": "script"},
+                    {"type": "expression"},
+                ),
             }
         ],
         Optional("switches", default={}): {
diff --git a/server/libs/tclint/comments.py b/server/libs/tclint/comments.py
index d7b878d..5e69fe8 100644
--- a/server/libs/tclint/comments.py
+++ b/server/libs/tclint/comments.py
@@ -37,24 +37,25 @@ class CommentVisitor(Visitor):
 
         command = split[0]
 
-        rule_strs = []
+        rule_strs: list[str] = []
         if len(split) > 1:
             rest = split[-1]
-            rule_strs = rest.split("--", 1)[0]
-            rule_strs = rule_strs.replace(" ", "")
-            rule_strs = rule_strs.split(",")
+            s = rest.split("--", 1)[0]
+            s = s.replace(" ", "")
+            rule_strs = s.split(",")
 
-        rules = []
+        rules: list[Rule] = []
         if not rule_strs:
             # default if no rules specified is all violation types
             rules = ALL_RULES
         else:
-            for rule in rule_strs:
+            for rule_str in rule_strs:
                 try:
-                    rules.append(Rule(rule))
+                    rules.append(Rule(rule_str))
                 except ValueError:
                     self._warning(
-                        f"unknown rule '{rule}' provided to '{command}'", comment.pos
+                        f"unknown rule '{rule_str}' provided to '{command}'",
+                        comment.pos,
                     )
 
         if command == "tclint-disable":
diff --git a/server/libs/tclint/config.py b/server/libs/tclint/config.py
index f8be7bc..dc44057 100644
--- a/server/libs/tclint/config.py
+++ b/server/libs/tclint/config.py
@@ -1,20 +1,38 @@
 import argparse
-import pathlib
-from typing import Union, List
-from typing import Optional as OptionalType
 import dataclasses
+import pathlib
 import sys
+from enum import IntEnum
+from typing import Callable, NamedTuple
+from typing import Optional as OptionalType
 
 if sys.version_info >= (3, 11):
     import tomllib
 else:
     import tomli as tomllib
 
-from voluptuous import Schema, Optional, And, Coerce, Invalid, Range
+from voluptuous import And, Coerce, Invalid, Optional, Range, Schema
 
 from tclint.violations import Rule
 
 
+class SpacesInBraces(IntEnum):
+    """Enum listing valid values for --spaces-in-braces."""
+
+    NEVER = 0
+    ALWAYS = 1
+    BALANCED_NO = 2
+    BALANCED_YES = 3
+
+
+class ExcludePattern(NamedTuple):
+    """Exclude patterns are applied relative to a certain root. This dataclass is used
+    to bundle the two."""
+
+    pattern: str
+    root: pathlib.Path
+
+
 @dataclasses.dataclass
 class Config:
     """This dataclass defines the supported Config fields and their default
@@ -24,17 +42,20 @@ class Config:
     validation (and normalization) is defined by `validators` below.
     """
 
-    exclude: List[str] = dataclasses.field(default_factory=list)
-    ignore: List[Rule] = dataclasses.field(default_factory=list)
-    commands: OptionalType[pathlib.Path] = dataclasses.field(default=None)
-    extensions: List[str] = dataclasses.field(
+    exclude: list[ExcludePattern] = dataclasses.field(default_factory=list)
+    ignore: list[Rule] = dataclasses.field(default_factory=list)
+    commands: list[str | pathlib.Path] = dataclasses.field(default_factory=list)
+    extensions: list[str] = dataclasses.field(
         default_factory=lambda: ["tcl", "sdc", "xdc", "upf"]
     )
-    style_indent: OptionalType[Union[str, int]] = dataclasses.field(default=None)
+    style_indent: OptionalType[str | int] = dataclasses.field(default=None)
     style_line_length: int = dataclasses.field(default=100)
     style_max_blank_lines: int = dataclasses.field(default=2)
     style_indent_namespace_eval: bool = dataclasses.field(default=True)
-    style_spaces_in_braces: bool = dataclasses.field(default=False)
+    style_spaces_in_braces: SpacesInBraces = dataclasses.field(
+        default=SpacesInBraces.NEVER
+    )
+    style_emacs: bool = dataclasses.field(default=False)
 
     def apply_cli_args(self, args):
         args_dict = vars(args)
@@ -63,273 +84,42 @@ class Config:
             return "\t"
         elif isinstance(self.style_indent, int):
             return " " * self.style_indent
+        elif isinstance(self.style_indent, tuple):
+            return " " * self.style_indent[0]
 
         # Should be unreachable, validated on ingestion of config
         raise ValueError(
             f"unexpected value for config.style_indent: {self.style_indent}"
         )
 
+    def get_indent_mixed_tab_size(self) -> int:
+        if isinstance(self.style_indent, tuple):
+            return self.style_indent[1]
+        return 0
 
-# Validators using `voluptuous` library that check and normalize config inputs.
-# Used for checking both config files as well as config-related CLI args.
-
-# Using these for CLI args adds a constraint that all non-boolean validators
-# need to be able to normalize a value from a string. This means one could put
-# e.g. a string representation of a list into a .toml config file, but we shouldn't
-# document this, since it won't be considered stable behavior.
-
-
-def _str2list(s):
-    """Handles string-to-list normalization."""
-    if isinstance(s, str):
-        if s == "":
-            return []
-        return [v.strip() for v in s.split(",")]
-    return s
-
-
-_VALIDATORS = {
-    # note: it's ok if paths don't exist - allows for generic
-    # configurations with directories like .git/ excluded
-    "exclude": _str2list,
-    "ignore": And(
-        _str2list,
-        [
-            Coerce(Rule, msg="invalid rule ID"),
-        ],
-    ),
-    "commands": Coerce(pathlib.Path),
-    "extensions": _str2list,
-    "style_indent": Coerce(
-        lambda v: v if v == "tab" else int(v), msg="expected integer or 'tab'"
-    ),
-    "style_line_length": Coerce(int),
-    "style_max_blank_lines": And(
-        Coerce(int),
-        # we could technically support i >= 0, but I think 0 would be a weird
-        # setting and this lets us ignore pluralizing the violation message :)
-        Range(min=1),
-    ),
-    "style_indent_namespace_eval": bool,
-    "style_spaces_in_braces": bool,
-}
-
-
-def _validate_config(config):
-    """Validates dictionary read from TOML config file. Individual value validators
-    are implemented in the global dict, this defines the actual structure of the
-    schema."""
-
-    base_config = {
-        Optional("ignore"): _VALIDATORS["ignore"],
-        Optional("commands"): _VALIDATORS["commands"],
-        Optional("style"): {
-            Optional("indent"): _VALIDATORS["style_indent"],
-            Optional("line-length"): _VALIDATORS["style_line_length"],
-            Optional("max-blank-lines"): _VALIDATORS["style_max_blank_lines"],
-            Optional("indent-namespace-eval"): _VALIDATORS[
-                "style_indent_namespace_eval"
-            ],
-            Optional("spaces-in-braces"): _VALIDATORS["style_spaces_in_braces"],
-        },
-    }
-
-    schema = Schema({
-        # exclude and extensions can only be used in global context
-        Optional("exclude"): _VALIDATORS["exclude"],
-        Optional("extensions"): _VALIDATORS["extensions"],
-        **base_config,
-        Optional("fileset"): Schema(
-            [{"paths": [Coerce(pathlib.Path)], **base_config}], required=True
-        ),
-    })
-
-    try:
-        return schema(config)
-    except Invalid as e:
-        if not e.path:
-            raise ConfigError(e.error_message)
-
-        # Stringify error path to my own taste.
-        path = []
-        for item in e.path:
-            if isinstance(item, int):
-                # Brackets around indices
-                if len(path) > 0:
-                    path[-1] += f"[{item}]"
-                else:
-                    path.append(f"[{item}]")
-            else:
-                path.append(str(item))
-
-        raise ConfigError(f"{e.error_message} ({'.'.join(path)})")
-
-
-def _validator(key):
-    def func(s):
-        try:
-            return Schema(_VALIDATORS[key])(s)
-        except Invalid as e:
-            raise argparse.ArgumentTypeError(str(e))
-
-    return func
-
-
-def _add_bool(group, parser, dest, yes_flag, no_flag):
-    mutex_group = group.add_mutually_exclusive_group(required=False)
-    mutex_group.add_argument(yes_flag, dest=dest, action="store_true")
-    mutex_group.add_argument(no_flag, dest=dest, action="store_false")
-    parser.set_defaults(**{dest: None})
-
-
-def setup_common_config_cli_args(config_group):
-    config_group.add_argument(
-        "--exclude", type=_validator("exclude"), metavar='"path1, path2, ..."'
-    )
-    config_group.add_argument(
-        "--extend-exclude", type=_validator("exclude"), metavar='"path1, path2, ..."'
-    )
-    config_group.add_argument(
-        "--extensions", type=_validator("extensions"), metavar='"tcl, xdc, ..."'
-    )
-    config_group.add_argument(
-        "--commands", type=_validator("commands"), metavar=""
-    )
-
-
-def setup_config_cli_args(parser):
-    """This method defines config-related CLI arguments.
-
-    The destvars of these switches should match the fields of Config.
-    """
-    config_group = parser.add_argument_group("configuration arguments")
-
-    config_group.add_argument(
-        "--ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."'
-    )
-    config_group.add_argument(
-        "--extend-ignore", type=_validator("ignore"), metavar='"rule1, rule2, ..."'
-    )
-    setup_common_config_cli_args(config_group)
-    config_group.add_argument(
-        "--style-line-length",
-        type=_validator("style_line_length"),
-        metavar="",
-    )
-
-
-def setup_tclfmt_config_cli_args(parser):
-    """This method defines the subset of config-related CLI arguments used by tclfmt.
-
-    The destvars of these switches should match the fields of Config.
-    """
-    config_group = parser.add_argument_group("configuration arguments")
-
-    setup_common_config_cli_args(config_group)
-
-    config_group.add_argument(
-        "--indent",
-        type=_validator("style_indent"),
-        metavar="",
-        dest="style_indent",
-    )
-    config_group.add_argument(
-        "--max-blank-lines",
-        type=_validator("style_max_blank_lines"),
-        metavar="",
-        dest="style_max_blank_lines",
-    )
-    _add_bool(
-        config_group,
-        parser,
-        "style_indent_namespace_eval",
-        "--indent-namespace-eval",
-        "--no-indent-namespace-eval",
-    )
-    _add_bool(
-        config_group,
-        parser,
-        "style_spaces_in_braces",
-        "--spaces-in-braces",
-        "--no-spaces-in-braces",
-    )
-
-
-def _flatten(d, prefix=None):
-    """Flattens TOML config dictionary structure to match the flat set of fields
-    expected by Config dataclass."""
-    if prefix is None:
-        prefix = []
-
-    flat = {}
-    for k, v in d.items():
-        if isinstance(v, dict):
-            flat.update(_flatten(v, prefix=prefix + [k]))
-        else:
-            flat["_".join(prefix + [k]).replace("-", "_")] = v
-
-    return flat
-
-
-class RunConfig:
-    """Class that holds information about both global and fileset configs. User
-    code can get a Config object that applies to a particular file by calling
-    get_from_path() and supplying that file's path."""
-
-    def __init__(self, global_config=None, fileset_configs=None):
-        if global_config is not None:
-            self._global_config = global_config
-        else:
-            self._global_config = Config()
-
-        self._fileset_configs = [
-            # ([pathlib.Path...], Config])
-        ]
-        if fileset_configs is not None:
-            self._fileset_configs = fileset_configs
-
-    @property
-    def exclude(self):
-        return self._global_config.exclude
-
-    @property
-    def extensions(self):
-        return self._global_config.extensions
+    def diff(self) -> str:
+        """Return string representation of Config only showing fields that differ from
+        default instance."""
+        default_config = Config()
+        values = []
+        for field in dataclasses.fields(self):
+            value = getattr(self, field.name)
+            default = getattr(default_config, field.name)
+            if value != default:
+                values.append(f"{field.name}={value}")
+        return f"Config({', '.join(values)})"
 
     @classmethod
     def from_dict(cls, config_dict: dict, root: pathlib.Path):
-        config_dict = _validate_config(config_dict)
-        try:
-            fileset_config_dicts = config_dict.pop("fileset")
-        except KeyError:
-            fileset_config_dicts = []
-
+        config_dict = _validate_config(config_dict, root)
         config_dict = _flatten(config_dict)
-        global_config = Config(**config_dict)
-
-        fileset_configs = []
-        for fileset_config in fileset_config_dicts:
-            paths = []
-            for path in fileset_config.pop("paths"):
-                if not path.is_absolute():
-                    path = root / path
-                paths.append(path.resolve())
-
-            fileset_config = _flatten(fileset_config)
-
-            # pull in default values from global config
-            full_fileset_config = config_dict.copy()
-            full_fileset_config.update(fileset_config)
-
-            fileset_configs.append((paths, Config(**full_fileset_config)))
-
-        return cls(global_config, fileset_configs)
+        return cls(**config_dict)
 
     @classmethod
-    def from_path(cls, path: Union[str, pathlib.Path], root: pathlib.Path):
+    def from_path(cls, path: str | pathlib.Path, root: pathlib.Path):
         path = pathlib.Path(path)
 
-        if not path.exists():
+        if not path.exists() or path.is_dir():
             raise FileNotFoundError
 
         with open(path, "rb") as f:
@@ -365,22 +155,306 @@ class RunConfig:
         except ConfigError as e:
             raise ConfigError(f"pyproject.toml: {e}")
 
-    def get_for_path(self, path) -> Config:
-        if path is None:
-            return self._global_config
 
-        path = path.resolve()
-        for fileset_paths, config in self._fileset_configs:
-            for fileset_path in fileset_paths:
-                if path.is_relative_to(fileset_path):
-                    return config
+# Validators using `voluptuous` library that check and normalize config inputs.
+# Used for checking both config files as well as config-related CLI args.
 
-        return self._global_config
+# Using these for CLI args adds a constraint that all non-boolean validators
+# need to be able to normalize a value from a string. This means one could put
+# e.g. a string representation of a list into a .toml config file, but we shouldn't
+# document this, since it won't be considered stable behavior.
 
-    def apply_cli_args(self, args):
-        self._global_config.apply_cli_args(args)
-        for _, fileset_config in self._fileset_configs:
-            fileset_config.apply_cli_args(args)
+
+def _str2list(s):
+    """Handles string-to-list normalization."""
+    if isinstance(s, str):
+        if s == "":
+            return []
+        return [v.strip() for v in s.split(",")]
+    return s
+
+
+def _add_root(root: pathlib.Path) -> Callable[[pathlib.Path], pathlib.Path]:
+    """Resolve path relative to `root.`"""
+
+    def _path(path: pathlib.Path) -> pathlib.Path:
+        path = path.expanduser()
+        if not path.is_absolute():
+            path = root / path
+        return path
+
+    return _path
+
+
+def parse_mixed(v: str) -> tuple[int, int]:
+    """Parse --indent=mixed,,."""
+    s = v.split(",")
+    if not (len(s) == 3 and s[0] == "mixed" and s[1].isdigit() and s[2].isdigit()):
+        raise ValueError()
+    return (int(s[1]), int(s[2]))
+
+
+# Define validators as module variables so they can be reused for config file schema
+# validation and CLI argument parsing.
+
+
+def _validate_exclude(root):
+    """Along with parsing the list, bundles exclude patterns with their root."""
+    return And(_str2list, [lambda p: ExcludePattern(p, root)])
+
+
+_validate_ignore = And(
+    _str2list,
+    [
+        Coerce(Rule, msg="invalid rule ID"),
+    ],
+)
+
+
+def _validate_commands(root):
+    add_root = _add_root(root)
+
+    def _process(command: str):
+        path = add_root(pathlib.Path(command))
+        if path.exists():
+            return path
+        return command
+
+    return And(_str2list, [_process])
+
+
+_validate_extensions = _str2list
+_validate_style_indent = Coerce(
+    lambda v: (
+        v
+        if v == "tab"
+        else (
+            int(v)
+            if isinstance(v, int) or (isinstance(v, str) and v.isdigit())
+            else parse_mixed(v)
+        )
+    ),
+    msg="expected integer, 'tab', or 'mixed',integer,integer",
+)
+_validate_style_line_length = Coerce(int)
+_validate_style_max_blank_lines = And(
+    Coerce(int),
+    Range(min=1),
+)
+_validate_style_indent_namespace_eval = bool
+_validate_style_emacs = bool
+
+
+def parse_spaces_in_braces(v: str | bool) -> SpacesInBraces:
+    if isinstance(v, bool):
+        # Handle spaces-in-braces = true/false in config file.
+        return SpacesInBraces.ALWAYS if v else SpacesInBraces.NEVER
+    if v == "never":
+        return SpacesInBraces.NEVER
+    if v == "always":
+        return SpacesInBraces.ALWAYS
+    if v == "balanced-no":
+        return SpacesInBraces.BALANCED_NO
+    if v == "balanced-yes":
+        return SpacesInBraces.BALANCED_YES
+    raise ValueError()
+
+
+_validate_style_spaces_in_braces = Coerce(
+    lambda v: (parse_spaces_in_braces(v)),
+    msg="always, never, balanced-yes, or balanced-no",
+)
+
+_validate_style_no_spaces_in_braces = Coerce(lambda v: (v))
+
+
+def _validate_config(config: dict, root: pathlib.Path):
+    """Validates dictionary read from TOML config file. Individual value validators
+    are implemented in the module-level variables above; this defines the actual
+    structure of the schema.
+
+    root is used to resolve values that may be relative to a certain path.
+    """
+    schema = Schema({
+        Optional("exclude"): _validate_exclude(root),
+        Optional("extensions"): _validate_extensions,
+        Optional("ignore"): _validate_ignore,
+        Optional("commands"): _validate_commands(root),
+        Optional("style"): {
+            Optional("indent"): _validate_style_indent,
+            Optional("line-length"): _validate_style_line_length,
+            Optional("max-blank-lines"): _validate_style_max_blank_lines,
+            Optional("indent-namespace-eval"): _validate_style_indent_namespace_eval,
+            Optional("spaces-in-braces"): _validate_style_spaces_in_braces,
+            Optional("emacs"): _validate_style_emacs,
+        },
+    })
+
+    try:
+        return schema(config)
+    except Invalid as e:
+        if not e.path:
+            raise ConfigError(e.error_message)
+
+        # Stringify error path to my own taste.
+        path: list[str] = []
+        for item in e.path:
+            if isinstance(item, int):
+                # Brackets around indices
+                if len(path) > 0:
+                    path[-1] += f"[{item}]"
+                else:
+                    path.append(f"[{item}]")
+            else:
+                path.append(str(item))
+
+        raise ConfigError(f"{e.error_message} ({'.'.join(path)})")
+
+
+def _argparsify(validator: Callable) -> Callable:
+    """Wrapper that applies a voluptuous-style validator and is compatible with
+    argparse's type argument."""
+
+    def func(s):
+        try:
+            return Schema(validator)(s)
+        except Invalid as e:
+            raise argparse.ArgumentTypeError(str(e))
+
+    return func
+
+
+def _add_bool(group, parser, dest, yes_flag, no_flag):
+    mutex_group = group.add_mutually_exclusive_group(required=False)
+    mutex_group.add_argument(yes_flag, dest=dest, action="store_true")
+    mutex_group.add_argument(no_flag, dest=dest, action="store_false")
+    parser.set_defaults(**{dest: None})
+
+
+def setup_common_config_cli_args(config_group, cwd: pathlib.Path):
+    """
+    This method defines config-related CLI arguments common to both tclint and tclfmt.
+
+    The destvars of these switches should match the fields of Config.
+
+    Relative paths and exclude patterns will be resolved relative to `cwd`. It may seem
+    weird to specify this in a "setup" function (as opposed to Config.apply_cli_args),
+    but the paths are resolved by the validator functions configured here. For our use
+    case, this is fine since the setup and application of the CLI args are close
+    together in the application code.
+    """
+    config_group.add_argument(
+        "--trust-plugins",
+        action="store_true",
+        help="enables execution of uninstalled Python-based command plugins",
+    )
+    config_group.add_argument(
+        "--exclude",
+        type=_argparsify(_validate_exclude(cwd)),
+        metavar='"path1, path2, ..."',
+    )
+    config_group.add_argument(
+        "--extend-exclude",
+        type=_argparsify(_validate_exclude(cwd)),
+        metavar='"path1, path2, ..."',
+    )
+    config_group.add_argument(
+        "--extensions",
+        type=_argparsify(_validate_extensions),
+        metavar='"tcl, xdc, ..."',
+    )
+    config_group.add_argument(
+        "--commands", type=_argparsify(_validate_commands(cwd)), metavar=""
+    )
+
+
+def setup_config_cli_args(parser, cwd: pathlib.Path):
+    """This method defines config-related CLI arguments used by tclint.
+
+    The destvars of these switches should match the fields of Config.
+    """
+    config_group = parser.add_argument_group("configuration arguments")
+
+    config_group.add_argument(
+        "--ignore", type=_argparsify(_validate_ignore), metavar='"rule1, rule2, ..."'
+    )
+    config_group.add_argument(
+        "--extend-ignore",
+        type=_argparsify(_validate_ignore),
+        metavar='"rule1, rule2, ..."',
+    )
+    setup_common_config_cli_args(config_group, cwd)
+    config_group.add_argument(
+        "--style-line-length",
+        type=_argparsify(_validate_style_line_length),
+        metavar="",
+    )
+
+
+def setup_tclfmt_config_cli_args(parser, cwd: pathlib.Path):
+    """This method defines the subset of config-related CLI arguments used by tclfmt.
+
+    The destvars of these switches should match the fields of Config.
+    """
+    config_group = parser.add_argument_group("configuration arguments")
+
+    setup_common_config_cli_args(config_group, cwd)
+
+    config_group.add_argument(
+        "--indent",
+        type=_argparsify(_validate_style_indent),
+        metavar="",
+        dest="style_indent",
+    )
+    config_group.add_argument(
+        "--max-blank-lines",
+        type=_argparsify(_validate_style_max_blank_lines),
+        metavar="",
+        dest="style_max_blank_lines",
+    )
+    _add_bool(
+        config_group,
+        parser,
+        "style_indent_namespace_eval",
+        "--indent-namespace-eval",
+        "--no-indent-namespace-eval",
+    )
+    config_group.add_argument(
+        "--spaces-in-braces",
+        type=_argparsify(_validate_style_spaces_in_braces),
+        metavar="",
+        dest="style_spaces_in_braces",
+    )
+    # Alias for --spaces-in-braces never.
+    config_group.add_argument(
+        "--no-spaces-in-braces",
+        action="store_const",
+        const=SpacesInBraces.NEVER,
+        dest="style_spaces_in_braces",
+    )
+    _add_bool(
+        config_group,
+        parser,
+        "style_emacs",
+        "--emacs",
+        "--no-emacs",
+    )
+
+
+def _flatten(d, prefix=None):
+    """Flattens TOML config dictionary structure to match the flat set of fields
+    expected by Config dataclass."""
+    if prefix is None:
+        prefix = []
+
+    flat = {}
+    for k, v in d.items():
+        if isinstance(v, dict):
+            flat.update(_flatten(v, prefix=prefix + [k]))
+        else:
+            flat["_".join(prefix + [k]).replace("-", "_")] = v
+
+    return flat
 
 
 class ConfigError(Exception):
@@ -390,34 +464,15 @@ class ConfigError(Exception):
 DEFAULT_CONFIGS = ("tclint.toml", ".tclint")
 
 
-def get_config(
-    config_path: OptionalType[pathlib.Path], root: pathlib.Path
-) -> OptionalType[RunConfig]:
-    """Loads a config file.
-
-    If `config_path` is supplied, attempts to read config file from this path. If the
-    path can't be found, raises a ConfigError.
-
-    Otherwise, attempts to read config from `root`/{tclint.toml, .tclint,
-    pyproject.toml} (in that order). If none of these files can be found, returns None.
-
-    `root` is also used to resolve some relative paths in the config file.
-    """
-    # user-supplied
-    if config_path is not None:
-        try:
-            return RunConfig.from_path(config_path, root)
-        except FileNotFoundError:
-            raise ConfigError(f"path {config_path} doesn't exist")
-
+def load_config_at(directory: pathlib.Path) -> OptionalType[Config]:
     for path in DEFAULT_CONFIGS:
         try:
-            return RunConfig.from_path(root / path, root)
+            return Config.from_path(directory / path, directory)
         except FileNotFoundError:
             pass
 
     try:
-        return RunConfig.from_pyproject(directory=root)
+        return Config.from_pyproject(directory=directory)
     except ConfigError as e:
         raise e
     except (FileNotFoundError, tomllib.TOMLDecodeError, KeyError):
diff --git a/server/libs/tclint/format.py b/server/libs/tclint/format.py
index cb01b7a..ee4d668 100644
--- a/server/libs/tclint/format.py
+++ b/server/libs/tclint/format.py
@@ -1,53 +1,56 @@
 import dataclasses
 import itertools
-import textwrap
-from typing import List, Tuple, Union
 import sys
 
-from tclint.syntax_tree import (
-    Node,
-    Script,
-    Command,
-    Comment,
-    CommandSub,
-    BareWord,
-    QuotedWord,
-    BracedWord,
-    CompoundBareWord,
-    VarSub,
-    ArgExpansion,
-    Expression,
-    BracedExpression,
-    ParenExpression,
-    UnaryOp,
-    BinaryOp,
-    TernaryOp,
-    Function,
-)
 from tclint.parser import Parser
-from tclint.syntax_tree import List as ListNode
+from tclint.syntax_tree import (
+    ArgExpansion,
+    BareWord,
+    BinaryOp,
+    BracedExpression,
+    BracedWord,
+    Command,
+    CommandSub,
+    Comment,
+    CompoundBareWord,
+    Expression,
+    Function,
+    List,
+    Node,
+    ParenExpression,
+    QuotedWord,
+    Script,
+    TernaryOp,
+    UnaryOp,
+    VarSub,
+)
 
 
 @dataclasses.dataclass
 class LiteralBlock:
-    block: List[str]
-    pos: Tuple[int, int]
-    end_pos: Tuple[int, int]
+    block: list[str]
+    pos: tuple[int, int]
+    end_pos: tuple[int, int]
 
 
 @dataclasses.dataclass
 class FormatterOpts:
     indent: str
     spaces_in_braces: bool
+    balanced_spaces_in_braces: bool
     max_blank_lines: int
     indent_namespace_eval: bool
+    indent_mixed_tab_size: int
+    emacs: bool
+    debug_whitespace: bool
 
 
 class Formatter:
     def __init__(self, opts: FormatterOpts):
         self.opts = opts
+        self.indent_mixed_tab_size = opts.indent_mixed_tab_size
 
-    def _indent(self, lines: List[str], indent: str) -> List[str]:
+    def _indent(self, lines: list[str], indent: str) -> list[str]:
         indented = []
         for line in lines:
             if line == "":
@@ -57,17 +60,70 @@ class Formatter:
 
         return indented
 
-    def _brace(self, lines: List[str]) -> List[str]:
-        spaces_in_braces = " " if self.opts.spaces_in_braces else ""
+    def space(self, debug_char, space=None):
+        """Returns a string required for indentation or separation.
+
+        By default, just returns a string of space. If enabled using
+        `--debug-whitespace`, returns a string of debug_char.
+        """
+        assert len(debug_char) == 1
+        if space is None:
+            space = self.opts.indent
+        if self.opts.debug_whitespace:
+            # Enable this to return a string of debug_char.
+            return len(space) * debug_char
+        return space
+
+    def get_spaces_in_braces(self, space: tuple[int, int]):
+        spaces_in_braces = self.space("A", " ") if self.opts.spaces_in_braces else ""
+        if not self.opts.balanced_spaces_in_braces:
+            # No balancing.
+            return spaces_in_braces
+
+        if space[0] == -1 and space[1] == -1:
+            # No info to do balancing.
+            return spaces_in_braces
+
+        assert not (space[0] == -1 and space[1] != -1)
+        if space[0] != -1 and space[1] == -1:
+            # we've got empty braces.  Keep "{}" and "{ }" as is, but normalize
+            # more than one space to a single space.
+            return min(space[0], 1) * self.space("B", " ")
+
+        # Normalize more than one space to a single space.
+        before = min(space[0], 1)
+        after = min(space[1], 1)
+        if before + after == 1:
+            # If we have an unbalanced expression like "{1 }" or "{ 1}",
+            # transform it to either "{1}" or "{ 1 }", using spaces_in_braces.
+            return spaces_in_braces
+
+        # Check that we have a balanced expression.
+        assert before == after
+        # Keep either "{1}" or "{ 1 }".
+        return before * self.space("C", " ")
+
+    def _brace(self, lines: list[str], space: tuple[int, int]) -> list[str]:
+        """Format content between braces.
+
+        The space argument indicates the amount of space in the input, for
+        instance:
+        - (1, 0) to represent 1 space before and no space after, for "{ 1}", and
+        - (0, -1) to represent no space, for "{}".
+        """
+
+        spaces_in_braces = self.get_spaces_in_braces(space)
         if lines == [""]:
+            # Empty braces.
             return ["{" + spaces_in_braces + "}"]
 
+        # Not empty braces.
         braced_lines = lines[:]
         braced_lines[0] = "{" + spaces_in_braces + lines[0]
         braced_lines[-1] += spaces_in_braces + "}"
         return braced_lines
 
-    def format(self, *nodes: Union[Node, LiteralBlock]) -> List[str]:
+    def format(self, *nodes: Node | LiteralBlock) -> list[str]:
         formatted = []
         for node in nodes:
             if isinstance(node, Script):
@@ -90,7 +146,7 @@ class Formatter:
                 formatted += self.format_var_sub(node)
             elif isinstance(node, ArgExpansion):
                 formatted += self.format_arg_expansion(node)
-            elif isinstance(node, ListNode):
+            elif isinstance(node, List):
                 formatted += self.format_list(node)
             elif isinstance(node, Expression):
                 formatted += self.format_expression(node)
@@ -113,10 +169,47 @@ class Formatter:
 
         return formatted
 
+    def reindent(self, lines: list[str]) -> list[str]:
+        """Apply mixed space/tab indentation scheme.
+
+        Apply the mixed space/tab indentation scheme as requested by
+        --indent=mixed,,.
+
+        The input is lines with indentation in the form of spaces and/or tabs.
+        This function transforms the indentation into a number of tabs,
+        followed by a number of spaces.
+
+        A more structural way of doing this would be to model input lines as a
+        tuple of an indentation level and a string, and use this function to
+        expand the indentation level, but that requires broader changes.
+        """
+        tab_size = self.indent_mixed_tab_size
+        if tab_size == 0:
+            return lines
+
+        fixed_lines = []
+        for line in lines:
+            # Split line into leading whitespace, and the rest.
+            after = line.lstrip()
+            split_pos = len(line) - len(after)
+            leading = line[0:split_pos]
+
+            # Expand tabs.
+            leading = leading.expandtabs(tab_size)
+
+            # Tabify.
+            leading = leading.replace(" " * tab_size, "\t")
+
+            fixed_lines.append(leading + after)
+
+        return fixed_lines
+
     def format_top(self, script: str, parser: Parser) -> str:
         tree = parser.parse(script)
         self.script = script.split("\n")
-        return "\n".join(self.format_script_contents(tree)) + "\n"
+        lines = self.format_script_contents(tree)
+        lines = self.reindent(lines)
+        return "\n".join(lines) + "\n"
 
     def format_partial(self, script: str, parser: Parser) -> str:
         """Formats a partial Tcl script.
@@ -139,11 +232,13 @@ class Formatter:
         tree = parser.parse(script)
         self.script = script.split("\n")
 
-        formatted = "\n".join(self.format_script_contents(tree))
+        lines = self._indent(self.format_script_contents(tree), indent)
+        lines = self.reindent(lines)
+        formatted = "\n".join(lines)
 
-        return leading + textwrap.indent(formatted, indent) + trailing
+        return leading + formatted + trailing
 
-    def format_script_contents(self, script: Union[Script, CommandSub]) -> List[str]:
+    def format_script_contents(self, script: Script | CommandSub) -> list[str]:
         to_format = []
         skip_formatting_start = None
         for child in script.children:
@@ -210,10 +305,17 @@ class Formatter:
 
         return formatted
 
-    def format_script(self, script: Script, should_indent=True) -> List[str]:
+    def format_script(self, script: Script, should_indent=True) -> list[str]:
         lines = self.format_script_contents(script)
         if script.pos[0] == script.end_pos[0]:
-            return self._brace(lines)
+            space_before = -1
+            space_after = -1
+            if len(script.children) != 0:
+                space_before = script.children[0].pos[1] - script.pos[1] - 1
+                space_after = script.end_pos[1] - script.children[-1].end_pos[1] - 1
+            else:
+                space_before = script.end_pos[1] - script.pos[1] - 2
+            return self._brace(lines, (space_before, space_after))
 
         # Usually, we enforce that multi-line scripts start on a new line after the open
         # brace. However, if a comment was originally on the same line as the open brace
@@ -225,15 +327,15 @@ class Formatter:
             and isinstance(script.children[0], Comment)
             and script.pos[0] == script.children[0].pos[0]
         ):
-            open_brace += " " + lines[0]
+            open_brace += self.space("D", " ") + lines[0]
             lines = lines[1:]
 
         if should_indent:
-            return [open_brace] + self._indent(lines, self.opts.indent) + ["}"]
+            return [open_brace] + self._indent(lines, self.space("E")) + ["}"]
         else:
             return [open_brace] + lines + ["}"]
 
-    def format_command(self, command: Command) -> List[str]:
+    def format_command(self, command: Command) -> list[str]:
         is_namespace_eval = (
             command.routine.contents == "namespace"
             and len(command.args) > 0
@@ -251,23 +353,27 @@ class Formatter:
                 child_lines = self.format(child)
 
             if last_line == child.pos[0]:
-                formatted[-1] += " "
+                formatted[-1] += self.space("F", " ")
+                if self.opts.emacs and child_lines[0][-1] == "\\":
+                    base_indent = (len(formatted[-1])) * self.space("G", " ")
+                else:
+                    base_indent = ""
                 formatted[-1] += child_lines[0]
             else:
-                formatted[-1] += " \\"
-                formatted.append(self.opts.indent + child_lines[0])
+                formatted[-1] += self.space("H", " ") + "\\"
+                formatted.append(self.space("I") + child_lines[0])
                 hanging_indent = True
 
             if hanging_indent:
-                formatted.extend(self._indent(child_lines[1:], self.opts.indent))
+                formatted.extend(self._indent(child_lines[1:], self.space("J")))
             else:
-                formatted.extend(child_lines[1:])
+                formatted.extend(self._indent(child_lines[1:], base_indent))
 
             last_line = child.end_pos[0]
 
         return formatted
 
-    def format_comment(self, comment: Comment) -> List[str]:
+    def format_comment(self, comment: Comment) -> list[str]:
         return [f"#{comment.value}"]
 
     def format_command_sub(self, command_sub):
@@ -278,21 +384,25 @@ class Formatter:
         contents = self.format_script_contents(command_sub)
         if len(command_sub.children) > 1 and len(contents) > 1:
             formatted.append("[")
-            formatted.extend(self._indent(contents, self.opts.indent))
+            formatted.extend(self._indent(contents, self.space("K")))
             formatted.append("]")
         else:
             formatted.append("[" + contents[0])
-            formatted.extend(contents[1:])
+            if self.opts.emacs:
+                indent = self.space("L", " ")
+            else:
+                indent = ""
+            formatted.extend(self._indent(contents[1:], indent))
             formatted[-1] += "]"
 
         return formatted
 
-    def format_bare_word(self, word) -> List[str]:
+    def format_bare_word(self, word) -> list[str]:
         # Property enforced by parser
         assert word.contents is not None
         return [word.contents]
 
-    def format_quoted_word(self, word) -> List[str]:
+    def format_quoted_word(self, word) -> list[str]:
         if word.contents is not None:
             return [f'"{word.contents}"']
 
@@ -302,11 +412,11 @@ class Formatter:
 
         return [f'"{formatted}"']
 
-    def format_braced_word(self, word) -> List[str]:
+    def format_braced_word(self, word) -> list[str]:
         assert word.contents is not None
         return [f"{{{word.contents}}}"]
 
-    def format_compound_bare_word(self, word) -> List[str]:
+    def format_compound_bare_word(self, word) -> list[str]:
         formatted = [""]
         for child in word.children:
             child_lines = self.format(child)
@@ -315,7 +425,7 @@ class Formatter:
 
         return formatted
 
-    def format_var_sub(self, varsub) -> List[str]:
+    def format_var_sub(self, varsub) -> list[str]:
         # We might be able to make the formatter infer whether braces are required, and
         # remove them from the syntax tree. For now it's easier to just mimic the
         # original format.
@@ -337,20 +447,20 @@ class Formatter:
 
         return formatted
 
-    def format_arg_expansion(self, arg_expansion) -> List[str]:
+    def format_arg_expansion(self, arg_expansion) -> list[str]:
         lines = self.format(arg_expansion.list)
         lines[0] = "{*}" + lines[0]
 
         return lines
 
-    def format_list(self, list_node) -> List[str]:
+    def format_list(self, list_node) -> list[str]:
         # Similar to Script, but the contents are a bit more straightforward.
         contents = [""]
         last_line = None
         for child in list_node.children:
             if last_line is not None:
                 if last_line == child.pos[0]:
-                    contents[-1] += " "
+                    contents[-1] += self.space("M", " ")
                 else:
                     newlines = child.pos[0] - last_line
                     newlines = min(newlines, 3)
@@ -363,18 +473,27 @@ class Formatter:
             last_line = child.end_pos[0]
 
         if list_node.pos[0] == list_node.end_pos[0]:
-            return self._brace(contents)
+            space_before = -1
+            space_after = -1
+            if len(list_node.children) != 0:
+                space_before = list_node.children[0].pos[1] - list_node.pos[1] - 1
+                space_after = (
+                    list_node.end_pos[1] - list_node.children[-1].end_pos[1] - 1
+                )
+            else:
+                space_before = list_node.end_pos[1] - list_node.pos[1] - 2
+            return self._brace(contents, (space_before, space_after))
 
-        return ["{"] + self._indent(contents, self.opts.indent) + ["}"]
+        return ["{"] + self._indent(contents, self.space("N")) + ["}"]
 
-    def format_expression(self, expr) -> List[str]:
+    def format_expression(self, expr) -> list[str]:
         formatted = [""]
         for child in expr.children:
             lines = self.format(child)
             formatted[-1] += lines[0]
             for line in lines[1:]:
                 formatted[-1] += " \\"
-                formatted += self._indent([line], self.opts.indent)
+                formatted += self._indent([line], self.space("O"))
 
         # Trick: we know there are quotes around the expression if the start of the
         # expression is a different column than its first child.
@@ -385,7 +504,7 @@ class Formatter:
 
         return formatted
 
-    def format_braced_expression(self, expr) -> List[str]:
+    def format_braced_expression(self, expr) -> list[str]:
         formatted = [""]
         for child in expr.children:
             lines = self.format(child)
@@ -393,11 +512,13 @@ class Formatter:
             formatted.extend(lines[1:])
 
         if expr.pos[0] == expr.end_pos[0]:
-            return self._brace(formatted)
+            space_before = expr.children[0].pos[1] - expr.pos[1] - 1
+            space_after = expr.end_pos[1] - expr.children[-1].end_pos[1] - 1
+            return self._brace(formatted, (space_before, space_after))
 
-        return ["{"] + self._indent(formatted, self.opts.indent) + ["}"]
+        return ["{"] + self._indent(formatted, self.space("P")) + ["}"]
 
-    def format_paren_expression(self, expr) -> List[str]:
+    def format_paren_expression(self, expr) -> list[str]:
         body = expr.body
 
         formatted = ["("]
@@ -408,7 +529,7 @@ class Formatter:
             formatted[-1] += lines[0]
             formatted.extend(lines[1:])
 
-        formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent)
+        formatted = formatted[0:1] + self._indent(formatted[1:], self.space("Q"))
 
         if expr.end_pos[0] != body.end_pos[0]:
             formatted.append(")")
@@ -425,7 +546,7 @@ class Formatter:
         lines[0] = op[0] + lines[0]
         return lines
 
-    def _format_op(self, expr) -> List[str]:
+    def _format_op(self, expr) -> list[str]:
         nodes = expr.children
         formatted = self.format(nodes[0])
 
@@ -435,23 +556,23 @@ class Formatter:
             if last.end_pos[0] != next.pos[0]:
                 formatted.extend(lines)
             else:
-                formatted[-1] += " "
+                formatted[-1] += self.space("R", " ")
                 formatted[-1] += lines[0]
                 formatted.extend(lines[1:])
             last = next
 
         return formatted
 
-    def format_binary_op(self, expr) -> List[str]:
+    def format_binary_op(self, expr) -> list[str]:
         return self._format_op(expr)
 
-    def format_ternary_op(self, expr) -> List[str]:
+    def format_ternary_op(self, expr) -> list[str]:
         return self._format_op(expr)
 
     def format_function(self, function):
-        name = self.format(function.name)
-        assert len(name) == 1
-        name = name[0]
+        name_parts = self.format(function.name)
+        assert len(name_parts) == 1
+        name = name_parts[0]
 
         formatted = [f"{name}("]
 
@@ -464,13 +585,13 @@ class Formatter:
                 formatted.extend(lines)
             else:
                 if i > 0:
-                    formatted[-1] += " "
+                    formatted[-1] += self.space("S", " ")
                 formatted[-1] += lines[0]
                 formatted.extend(lines[1:])
             last = child
 
         # indent any continuation lines, but we leave the closing paren dedented
-        formatted = formatted[0:1] + self._indent(formatted[1:], self.opts.indent)
+        formatted = formatted[0:1] + self._indent(formatted[1:], self.space("T"))
 
         if last.end_pos[0] != function.end_pos[0]:
             formatted.append(")")
diff --git a/server/libs/tclint/lexer.py b/server/libs/tclint/lexer.py
index dc18312..e4ede03 100644
--- a/server/libs/tclint/lexer.py
+++ b/server/libs/tclint/lexer.py
@@ -1,5 +1,4 @@
 import ply.lex as lex
-from typing import Tuple
 
 TOK_BACKSLASH_NEWLINE = "BACKSLASH_NEWLINE"
 TOK_BACKSLASH_SUB = "BACKSLASH_SUB"
@@ -28,7 +27,7 @@ STATE_BRACEDWORD = "bracedword"
 
 
 class TclSyntaxError(Exception):
-    def __init__(self, message, start: Tuple[int, int], end: Tuple[int, int]):
+    def __init__(self, message, start: tuple[int, int], end: tuple[int, int]):
         super().__init__(message)
         self.start = start
         self.end = end
@@ -239,5 +238,5 @@ class Lexer:
         self.next()
 
     def assert_(self, *tokens):
-        assert self.current.type in tokens
+        assert self.type() in tokens
         self.next()
diff --git a/server/libs/tclint/parser.py b/server/libs/tclint/parser.py
index b429838..f621b0b 100644
--- a/server/libs/tclint/parser.py
+++ b/server/libs/tclint/parser.py
@@ -1,51 +1,55 @@
-import string
+import io
 import re
+import string
+from typing import Optional, Tuple
 
+from tclint.commands import CommandArgError
+from tclint.commands import builtin as _builtin
+from tclint.commands.checks import check_command
 from tclint.lexer import (
-    Lexer,
-    TclSyntaxError,
     STATE_BRACEDWORD,
+    TOK_ALPHA_CHARS,
+    TOK_ARG_EXPANSION,
     TOK_BACKSLASH_NEWLINE,
+    TOK_DOLLAR,
+    TOK_EOF,
+    TOK_HASH,
+    TOK_LBRACE,
+    TOK_LBRACKET,
+    TOK_LPAREN,
+    TOK_NAMESPACE_SEP,
     TOK_NEWLINE,
+    TOK_NUM_CHARS,
+    TOK_QUOTE,
+    TOK_RBRACE,
+    TOK_RBRACKET,
+    TOK_RPAREN,
     TOK_SEMI,
     TOK_WS,
-    TOK_QUOTE,
-    TOK_ARG_EXPANSION,
-    TOK_LBRACE,
-    TOK_RBRACE,
-    TOK_LBRACKET,
-    TOK_RBRACKET,
-    TOK_DOLLAR,
-    TOK_LPAREN,
-    TOK_RPAREN,
-    TOK_HASH,
-    TOK_ALPHA_CHARS,
-    TOK_NUM_CHARS,
-    TOK_NAMESPACE_SEP,
-    TOK_EOF,
+    Lexer,
+    TclSyntaxError,
 )
 from tclint.syntax_tree import (
-    Script,
-    Comment,
+    ArgExpansion,
+    BareWord,
+    BinaryOp,
+    BracedExpression,
+    BracedWord,
     Command,
     CommandSub,
-    ArgExpansion,
-    VarSub,
-    BareWord,
-    BracedWord,
-    QuotedWord,
+    Comment,
     CompoundBareWord,
-    List,
     Expression,
-    BracedExpression,
-    ParenExpression,
-    UnaryOp,
-    BinaryOp,
-    TernaryOp,
     Function,
+    List,
+    Node,
+    ParenExpression,
+    QuotedWord,
+    Script,
+    TernaryOp,
+    UnaryOp,
+    VarSub,
 )
-from tclint.commands import CommandArgError, get_commands
-from tclint.commands.checks import check_command
 from tclint.violations import Rule, Violation
 
 
@@ -98,22 +102,26 @@ class _Word:
 
 
 class Parser:
-    def __init__(self, debug=False, command_plugins=None):
+    def __init__(self, debug=False, commands: Optional[dict] = None):
         self._debug = debug
         self._debug_indent = 0
         # TODO: better way to handle this?
-        self.violations = []
+        self.violations: list[Violation] = []
 
-        if command_plugins is None:
-            command_plugins = []
-        self._commands = get_commands(command_plugins)
+        if commands is None:
+            commands = _builtin.commands
+        self._commands = commands
+
+        # Used to normalize newlines consistently with open()'s universal newlines mode.
+        self._decoder = io.IncrementalNewlineDecoder(None, True)
 
     def debug(self, *msg):
         if self._debug:
             print("  " * self._debug_indent, end="")
             print(*msg)
 
-    def parse(self, script, pos=None):
+    def parse(self, script: str, pos: Optional[Tuple[int, int]] = None):
+        script = self._decoder.decode(script, True)
         lexer = Lexer(pos=pos)
         lexer.input(script)
         tree = self._parse_script(lexer, in_command_sub=False)
@@ -191,6 +199,7 @@ class Parser:
         self._debug_indent += 1
         pos = ts.pos()
 
+        script: Script | CommandSub
         if in_command_sub:
             script = CommandSub(pos=pos)
         else:
@@ -520,7 +529,7 @@ class Parser:
         self._debug_indent -= 1
         return script
 
-    def parse_list(self, node):
+    def parse_list(self, node: Node) -> List:
         """Parse contents of node as Tcl list. This is a distinct entry point
         that doesn't get used when generating the main syntax tree, but is used
         in command-specific argument parsing.
@@ -756,9 +765,14 @@ class Parser:
             operator = ts.value()
             ts.next()
         else:
-            raise TclSyntaxError(
-                f"invalid operator in expression: {ts.value()}", pos, ts.pos()
-            )
+            message = "invalid operator in expression: "
+            if ts.value() == "\\ ":
+                message += (
+                    "\\ (check for trailing whitespace if it's the end of the line)"
+                )
+            else:
+                message += ts.value()
+            raise TclSyntaxError(message, pos, ts.pos())
 
         return BareWord(operator, pos=pos, end_pos=ts.pos())
 
diff --git a/server/libs/tclint/plugins/expect.py b/server/libs/tclint/plugins/expect.py
new file mode 100644
index 0000000..ef05745
--- /dev/null
+++ b/server/libs/tclint/plugins/expect.py
@@ -0,0 +1,64 @@
+"""Plugin for validating expect commands.
+
+See https://www.tcl-lang.org/man/expect5.31/expect.1.html for reference.
+"""
+
+from tclint.commands.builtin import commands as builtins
+from tclint.commands.checks import (
+    CommandArgError,
+    check_arg_spec,
+    map_switches,
+)
+
+
+def close(args, parser):
+    """close [-slave] [-onexec 0|1] [-i spawn_id]"""
+
+    # Fancy handling since it seems like expect will fall back to Tcl's built-in close
+    # if none of its switches match, and we can't express this in a static arg spec.
+    # Try to replicate the logic here, reusing the functions used by `check_arg_spec`.
+
+    if len(args) == 0:
+        # No args is okay.
+        return None
+
+    expect_switches = {
+        "-slave": {"required": False, "repeated": False, "value": None},
+        "-onexec": {
+            "required": False,
+            "repeated": False,
+            "value": {"type": "any"},
+            "metavar": "0|1",
+        },
+        "-i": {
+            "required": False,
+            "repeated": False,
+            "value": {"type": "any"},
+            "metavar": "spawn_id",
+        },
+    }
+
+    mapped, positionals = map_switches(args, expect_switches, "close")
+    if len(mapped) > 0:
+        if len(positionals) > 0:
+            raise CommandArgError(
+                f"too many arguments for close: got {len(positionals)}, expected"
+                " no more than 0"
+            )
+        return None
+
+    return check_arg_spec("close", args, parser, builtins["close"])
+
+
+commands = {
+    "close": close,
+    "exit": {
+        "positionals": [
+            # TODO: break out into switches once we have a way to support -onexit's
+            # optional value.
+            {"name": "opts", "value": {"type": "variadic"}, "required": False},
+            # TODO: add (positive) integer type.
+            {"name": "status", "value": {"type": "any"}, "required": False},
+        ],
+    },
+}
diff --git a/server/libs/tclint/symbol_table.py b/server/libs/tclint/symbol_table.py
new file mode 100644
index 0000000..414b8a6
--- /dev/null
+++ b/server/libs/tclint/symbol_table.py
@@ -0,0 +1,45 @@
+import logging
+from collections import defaultdict
+
+from tclint.syntax_tree import Command, CommandSub, Node, Script, Visitor
+
+
+class SymbolTable:
+    """Holds a symbol table (links symbols to nodes)."""
+
+    def __init__(self) -> None:
+        self.proc_def: defaultdict[str, list[Node]] = defaultdict(list)
+
+    def add_proc_definition(self, command: Command) -> None:
+        """Add definition of procedure"""
+        # command holds the "proc" keyword, so the proc name is 1st argument
+        proc_name_node = command.args[0]
+        proc_name = proc_name_node.contents
+        if not proc_name:
+            return
+        logging.debug(
+            f"Definition of proc '{proc_name}' at {proc_name_node._pos_str()}"
+        )
+        self.proc_def[proc_name].append(proc_name_node)
+
+    def lookup_proc_definitions(self, symbol_text: str) -> list[Node]:
+        """Lookup definitions of the procedure pointed at by node"""
+        if symbol_text is None or symbol_text not in self.proc_def:
+            return []
+        return self.proc_def[symbol_text]
+
+
+class SymbolTableBuilder(Visitor):
+    """Builds a symbol table."""
+
+    def __init__(self):
+        self.table = SymbolTable()
+
+    def build(self, tree: CommandSub | Script) -> SymbolTable:
+        """Run the builder visitor through the syntax tree, building a table."""
+        tree.accept(self, recurse=True)
+        return self.table
+
+    def visit_command(self, command: Command) -> None:
+        if command.routine.contents == "proc":
+            self.table.add_proc_definition(command)
diff --git a/server/libs/tclint/syntax_tree.py b/server/libs/tclint/syntax_tree.py
index d11f6df..cc11a27 100644
--- a/server/libs/tclint/syntax_tree.py
+++ b/server/libs/tclint/syntax_tree.py
@@ -1,4 +1,6 @@
-"""Classes for representing and interacting with Tcl syntax trees. """
+"""Classes for representing and interacting with Tcl syntax trees."""
+
+from __future__ import annotations
 
 
 class Visitor:
@@ -222,6 +224,31 @@ class Node:
         for child in self.children:
             child.accept(visitor, recurse=True)
 
+    def _pos_match(self, line: int, col: int) -> bool:
+        """Return True if pos is within this node's block"""
+        if self.pos is None:
+            return False
+        if self.end_pos is None:
+            return line == self.pos[0] and col == self.pos[1]
+        return (
+            line >= self.pos[0]
+            and line <= self.end_pos[0]
+            and (col >= self.pos[1] or line > self.pos[0])
+            and (col < self.end_pos[1] or line < self.end_pos[0])
+        )
+
+    def find_by_pos(self, line: int, col: int) -> Node | None:
+        """Find the deepest child node in the tree (i.e. most granular match) that
+        matches the given position."""
+        if not self._pos_match(line, col):
+            return None
+
+        for child in self.children:
+            if child._pos_match(line, col):
+                return child.find_by_pos(line, col)
+
+        return self
+
 
 class Script(Node):
     def __init__(self, *args, **kwargs):
@@ -236,6 +263,11 @@ class Script(Node):
 
 
 class Comment(Node):
+    value: str
+
+    def __init__(self, value: str, pos=None, end_pos=None):
+        super().__init__(value, pos=pos, end_pos=end_pos)
+
     def accept(self, visitor, recurse=False):
         if recurse:
             self._recurse(visitor)
@@ -243,7 +275,7 @@ class Comment(Node):
 
 
 class Command(Node):
-    def __init__(self, routine, *args, pos=None, end_pos=None):
+    def __init__(self, routine: Node, *args: Node, pos=None, end_pos=None):
         self.routine = routine
         self.args = args
         super().__init__(routine, *args, pos=pos, end_pos=end_pos)
@@ -288,6 +320,8 @@ class BracedWord(Node):
 
     @property
     def contents_pos(self):
+        if self.line is None or self.col is None:
+            return None
         return (self.line, self.col + 1)
 
 
@@ -315,6 +349,8 @@ class QuotedWord(Node):
     def contents_pos(self):
         if self.contents is None:
             return None
+        if self.line is None or self.col is None:
+            return None
         return (self.line, self.col + 1)
 
 
@@ -337,7 +373,7 @@ class VarSub(Node):
 
 
 class ArgExpansion(Node):
-    def __init__(self, list, pos=None, end_pos=None):
+    def __init__(self, list: Node, pos=None, end_pos=None):
         self.list = list
         super().__init__(list, pos=pos, end_pos=end_pos)
 
@@ -346,6 +382,10 @@ class ArgExpansion(Node):
             self._recurse(visitor)
         visitor.visit_arg_expansion(self)
 
+    @property
+    def contents(self):
+        return self.list.contents
+
 
 class List(Node):
     """This Node currently exists exclusively for implementing the switch
diff --git a/server/libs/tclint/violations.py b/server/libs/tclint/violations.py
index 8b08329..4fbf1f6 100644
--- a/server/libs/tclint/violations.py
+++ b/server/libs/tclint/violations.py
@@ -1,5 +1,4 @@
 from enum import Enum
-from typing import Tuple
 
 
 class Rule(Enum):
@@ -16,6 +15,7 @@ class Rule(Enum):
     REDEFINED_BUILTIN = "redefined-builtin"
     UNBRACED_EXPR = "unbraced-expr"
     REDUNDANT_EXPR = "redundant-expr"
+    UNOPENED_QUOTE = "unopened-quote"
 
     def __str__(self):
         return self.value
@@ -26,7 +26,7 @@ ALL_RULES = [rule for rule in Rule]
 
 class Violation:
     def __init__(
-        self, id: Rule, message: str, start: Tuple[int, int], end: Tuple[int, int]
+        self, id: Rule, message: str, start: tuple[int, int], end: tuple[int, int]
     ):
         self.id = id
         self.message = message
@@ -44,7 +44,7 @@ class Violation:
 
     @classmethod
     def create(cls, id):
-        def func(message: str, start: Tuple[int, int], end: Tuple[int, int]):
+        def func(message: str, start: tuple[int, int], end: tuple[int, int]):
             return cls(id, message, start, end)
 
         return func
diff --git a/server/libs/typing_extensions-4.14.1.dist-info/INSTALLER b/server/libs/typing_extensions-4.14.1.dist-info/INSTALLER
deleted file mode 100644
index a1b589e..0000000
--- a/server/libs/typing_extensions-4.14.1.dist-info/INSTALLER
+++ /dev/null
@@ -1 +0,0 @@
-pip
diff --git a/server/libs/typing_extensions-4.14.1.dist-info/RECORD b/server/libs/typing_extensions-4.14.1.dist-info/RECORD
deleted file mode 100644
index a371f3a..0000000
--- a/server/libs/typing_extensions-4.14.1.dist-info/RECORD
+++ /dev/null
@@ -1,8 +0,0 @@
-__pycache__/typing_extensions.cpython-311.pyc,,
-typing_extensions-4.14.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-typing_extensions-4.14.1.dist-info/METADATA,sha256=8LS3enF0w3KyL4WYimlFfskcnkARg-sv_L6tHbPMS5s,2995
-typing_extensions-4.14.1.dist-info/RECORD,,
-typing_extensions-4.14.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-typing_extensions-4.14.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
-typing_extensions-4.14.1.dist-info/licenses/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936
-typing_extensions.py,sha256=Fh0lt5ZCgnzs7tyAhHOAfL0Zr829KYUxiR543ClwVgw,157408
diff --git a/server/libs/typing_extensions-4.15.0.dist-info/INSTALLER b/server/libs/typing_extensions-4.15.0.dist-info/INSTALLER
new file mode 100644
index 0000000..5c69047
--- /dev/null
+++ b/server/libs/typing_extensions-4.15.0.dist-info/INSTALLER
@@ -0,0 +1 @@
+uv
\ No newline at end of file
diff --git a/server/libs/typing_extensions-4.14.1.dist-info/METADATA b/server/libs/typing_extensions-4.15.0.dist-info/METADATA
similarity index 89%
rename from server/libs/typing_extensions-4.14.1.dist-info/METADATA
rename to server/libs/typing_extensions-4.15.0.dist-info/METADATA
index b1fe93f..b09cb50 100644
--- a/server/libs/typing_extensions-4.14.1.dist-info/METADATA
+++ b/server/libs/typing_extensions-4.15.0.dist-info/METADATA
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: typing_extensions
-Version: 4.14.1
+Version: 4.15.0
 Summary: Backported and Experimental Type Hints for Python 3.9+
 Keywords: annotations,backport,checker,checking,function,hinting,hints,type,typechecking,typehinting,typehints,typing
 Author-email: "Guido van Rossum, Jukka Lehtosalo, Ɓukasz Langa, Michael Lee" 
@@ -53,8 +53,12 @@ way as equivalent forms in `typing`.
 [Semantic Versioning](https://semver.org/). The
 major version will be incremented only for backwards-incompatible changes.
 Therefore, it's safe to depend
-on `typing_extensions` like this: `typing_extensions >=x.y, <(x+1)`,
+on `typing_extensions` like this: `typing_extensions ~=x.y`,
 where `x.y` is the first version that includes all features you need.
+[This](https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release)
+is equivalent to `typing_extensions >=x.y, <(x+1)`. Do not depend on `~= x.y.z`
+unless you really know what you're doing; that defeats the purpose of
+semantic versioning.
 
 ## Included items
 
diff --git a/server/libs/typing_extensions-4.15.0.dist-info/RECORD b/server/libs/typing_extensions-4.15.0.dist-info/RECORD
new file mode 100644
index 0000000..ec54c2e
--- /dev/null
+++ b/server/libs/typing_extensions-4.15.0.dist-info/RECORD
@@ -0,0 +1,7 @@
+typing_extensions-4.15.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
+typing_extensions-4.15.0.dist-info/METADATA,sha256=wTg3j-jxiTSsmd4GBTXFPsbBOu7WXpTDJkHafuMZKnI,3259
+typing_extensions-4.15.0.dist-info/RECORD,,
+typing_extensions-4.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+typing_extensions-4.15.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
+typing_extensions-4.15.0.dist-info/licenses/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936
+typing_extensions.py,sha256=Qz0R0XDTok0usGXrwb_oSM6n49fOaFZ6tSvqLUwvftg,160429
diff --git a/server/libs/zipp-3.23.0.dist-info/REQUESTED b/server/libs/typing_extensions-4.15.0.dist-info/REQUESTED
similarity index 100%
rename from server/libs/zipp-3.23.0.dist-info/REQUESTED
rename to server/libs/typing_extensions-4.15.0.dist-info/REQUESTED
diff --git a/server/libs/typing_extensions-4.14.1.dist-info/WHEEL b/server/libs/typing_extensions-4.15.0.dist-info/WHEEL
similarity index 100%
rename from server/libs/typing_extensions-4.14.1.dist-info/WHEEL
rename to server/libs/typing_extensions-4.15.0.dist-info/WHEEL
diff --git a/server/libs/typing_extensions-4.14.1.dist-info/licenses/LICENSE b/server/libs/typing_extensions-4.15.0.dist-info/licenses/LICENSE
similarity index 100%
rename from server/libs/typing_extensions-4.14.1.dist-info/licenses/LICENSE
rename to server/libs/typing_extensions-4.15.0.dist-info/licenses/LICENSE
diff --git a/server/libs/typing_extensions.py b/server/libs/typing_extensions.py
index efa09d5..77f33e1 100644
--- a/server/libs/typing_extensions.py
+++ b/server/libs/typing_extensions.py
@@ -14,6 +14,7 @@ import types as _types
 import typing
 import warnings
 
+# Breakpoint: https://github.com/python/cpython/pull/119891
 if sys.version_info >= (3, 14):
     import annotationlib
 
@@ -70,6 +71,7 @@ __all__ = [
     'clear_overloads',
     'dataclass_transform',
     'deprecated',
+    'disjoint_base',
     'Doc',
     'evaluate_forward_ref',
     'get_overloads',
@@ -100,6 +102,7 @@ __all__ = [
     'TypeGuard',
     'TypeIs',
     'TYPE_CHECKING',
+    'type_repr',
     'Never',
     'NoReturn',
     'ReadOnly',
@@ -151,6 +154,7 @@ __all__ = [
 # for backward compatibility
 PEP_560 = True
 GenericMeta = type
+# Breakpoint: https://github.com/python/cpython/pull/116129
 _PEP_696_IMPLEMENTED = sys.version_info >= (3, 13, 0, "beta")
 
 # Added with bpo-45166 to 3.10.1+ and some 3.9 versions
@@ -168,6 +172,7 @@ class _Sentinel:
 _marker = _Sentinel()
 
 
+# Breakpoint: https://github.com/python/cpython/pull/27342
 if sys.version_info >= (3, 10):
     def _should_collect_from_parameters(t):
         return isinstance(
@@ -189,6 +194,7 @@ T_co = typing.TypeVar('T_co', covariant=True)  # Any type covariant containers.
 T_contra = typing.TypeVar('T_contra', contravariant=True)  # Ditto contravariant.
 
 
+# Breakpoint: https://github.com/python/cpython/pull/31841
 if sys.version_info >= (3, 11):
     from typing import Any
 else:
@@ -277,6 +283,7 @@ class _ExtensionsSpecialForm(typing._SpecialForm, _root=True):
 
 Final = typing.Final
 
+# Breakpoint: https://github.com/python/cpython/pull/30530
 if sys.version_info >= (3, 11):
     final = typing.final
 else:
@@ -315,11 +322,39 @@ else:
         return f
 
 
+if hasattr(typing, "disjoint_base"):  # 3.15
+    disjoint_base = typing.disjoint_base
+else:
+    def disjoint_base(cls):
+        """This decorator marks a class as a disjoint base.
+
+        Child classes of a disjoint base cannot inherit from other disjoint bases that are
+        not parent classes of the disjoint base.
+
+        For example:
+
+            @disjoint_base
+            class Disjoint1: pass
+
+            @disjoint_base
+            class Disjoint2: pass
+
+            class Disjoint3(Disjoint1, Disjoint2): pass  # Type checker error
+
+        Type checkers can use knowledge of disjoint bases to detect unreachable code
+        and determine when two types can overlap.
+
+        See PEP 800."""
+        cls.__disjoint_base__ = True
+        return cls
+
+
 def IntVar(name):
     return typing.TypeVar(name)
 
 
 # A Literal bug was fixed in 3.11.0, 3.10.1 and 3.9.8
+# Breakpoint: https://github.com/python/cpython/pull/29334
 if sys.version_info >= (3, 10, 1):
     Literal = typing.Literal
 else:
@@ -480,6 +515,7 @@ Text = typing.Text
 TYPE_CHECKING = typing.TYPE_CHECKING
 
 
+# Breakpoint: https://github.com/python/cpython/pull/118681
 if sys.version_info >= (3, 13, 0, "beta"):
     from typing import AsyncContextManager, AsyncGenerator, ContextManager, Generator
 else:
@@ -590,6 +626,7 @@ def _caller(depth=1, default='__main__'):
 
 # `__match_args__` attribute was removed from protocol members in 3.13,
 # we want to backport this change to older Python versions.
+# Breakpoint: https://github.com/python/cpython/pull/110683
 if sys.version_info >= (3, 13):
     Protocol = typing.Protocol
 else:
@@ -770,6 +807,7 @@ else:
                 cls.__init__ = _no_init
 
 
+# Breakpoint: https://github.com/python/cpython/pull/113401
 if sys.version_info >= (3, 13):
     runtime_checkable = typing.runtime_checkable
 else:
@@ -830,6 +868,7 @@ runtime = runtime_checkable
 
 
 # Our version of runtime-checkable protocols is faster on Python <=3.11
+# Breakpoint: https://github.com/python/cpython/pull/112717
 if sys.version_info >= (3, 12):
     SupportsInt = typing.SupportsInt
     SupportsFloat = typing.SupportsFloat
@@ -1159,6 +1198,7 @@ else:
                     mutable_keys.add(annotation_key)
                     readonly_keys.discard(annotation_key)
 
+            # Breakpoint: https://github.com/python/cpython/pull/119891
             if sys.version_info >= (3, 14):
                 def __annotate__(format):
                     annos = {}
@@ -1249,6 +1289,7 @@ else:
             raise TypeError("TypedDict takes either a dict or keyword arguments,"
                             " but not both")
         if kwargs:
+            # Breakpoint: https://github.com/python/cpython/pull/104891
             if sys.version_info >= (3, 13):
                 raise TypeError("TypedDict takes no keyword arguments")
             warnings.warn(
@@ -1458,6 +1499,7 @@ else:  # <=3.13
         hint = typing.get_type_hints(
             obj, globalns=globalns, localns=localns, include_extras=True
         )
+        # Breakpoint: https://github.com/python/cpython/pull/30304
         if sys.version_info < (3, 11):
             _clean_optional(obj, hint, globalns, localns)
         if include_extras:
@@ -1530,7 +1572,8 @@ else:  # <=3.13
 
 # Python 3.9 has get_origin() and get_args() but those implementations don't support
 # ParamSpecArgs and ParamSpecKwargs, so only Python 3.10's versions will do.
-if sys.version_info[:2] >= (3, 10):
+# Breakpoint: https://github.com/python/cpython/pull/25298
+if sys.version_info >= (3, 10):
     get_origin = typing.get_origin
     get_args = typing.get_args
 # 3.9
@@ -2096,6 +2139,7 @@ def _concatenate_getitem(self, parameters):
 
 
 # 3.11+; Concatenate does not accept ellipsis in 3.10
+# Breakpoint: https://github.com/python/cpython/pull/30969
 if sys.version_info >= (3, 11):
     Concatenate = typing.Concatenate
 # <=3.10
@@ -2432,7 +2476,9 @@ For more information, see PEP 646 and PEP 692.
 """
 
 
-if sys.version_info >= (3, 12):  # PEP 692 changed the repr of Unpack[]
+# PEP 692 changed the repr of Unpack[]
+# Breakpoint: https://github.com/python/cpython/pull/104048
+if sys.version_info >= (3, 12):
     Unpack = typing.Unpack
 
     def _is_unpack(obj):
@@ -2695,8 +2741,9 @@ else:  # <=3.10
         raise AssertionError(f"Expected code to be unreachable, but got: {value}")
 
 
+# dataclass_transform exists in 3.11 but lacks the frozen_default parameter
+# Breakpoint: https://github.com/python/cpython/pull/99958
 if sys.version_info >= (3, 12):  # 3.12+
-    # dataclass_transform exists in 3.11 but lacks the frozen_default parameter
     dataclass_transform = typing.dataclass_transform
 else:  # <=3.11
     def dataclass_transform(
@@ -2827,6 +2874,7 @@ else:  # <=3.11
 
 
 # Python 3.13.3+ contains a fix for the wrapped __new__
+# Breakpoint: https://github.com/python/cpython/pull/132160
 if sys.version_info >= (3, 13, 3):
     deprecated = warnings.deprecated
 else:
@@ -2956,6 +3004,7 @@ else:
                     return arg(*args, **kwargs)
 
                 if asyncio.coroutines.iscoroutinefunction(arg):
+                    # Breakpoint: https://github.com/python/cpython/pull/99247
                     if sys.version_info >= (3, 12):
                         wrapper = inspect.markcoroutinefunction(wrapper)
                     else:
@@ -2969,6 +3018,7 @@ else:
                     f"a class or callable, not {arg!r}"
                 )
 
+# Breakpoint: https://github.com/python/cpython/pull/23702
 if sys.version_info < (3, 10):
     def _is_param_expr(arg):
         return arg is ... or isinstance(
@@ -3045,6 +3095,7 @@ if not hasattr(typing, "TypeVarTuple"):
 
                     expect_val = f"at least {elen}"
 
+            # Breakpoint: https://github.com/python/cpython/pull/27515
             things = "arguments" if sys.version_info >= (3, 10) else "parameters"
             raise TypeError(f"Too {'many' if alen > elen else 'few'} {things}"
                             f" for {cls}; actual {alen}, expected {expect_val}")
@@ -3238,6 +3289,7 @@ else:
 # This was explicitly disallowed in 3.9-3.10, and only half-worked in <=3.8.
 # On 3.12, we added __orig_bases__ to call-based NamedTuples
 # On 3.13, we deprecated kwargs-based NamedTuples
+# Breakpoint: https://github.com/python/cpython/pull/105609
 if sys.version_info >= (3, 13):
     NamedTuple = typing.NamedTuple
 else:
@@ -3313,6 +3365,7 @@ else:
                             # using add_note() until py312.
                             # Making sure exceptions are raised in the same way
                             # as in "normal" classes seems most important here.
+                            # Breakpoint: https://github.com/python/cpython/pull/95915
                             if sys.version_info >= (3, 12):
                                 e.add_note(msg)
                                 raise
@@ -3461,6 +3514,7 @@ else:
 
 # NewType is a class on Python 3.10+, making it pickleable
 # The error message for subclassing instances of NewType was improved on 3.11+
+# Breakpoint: https://github.com/python/cpython/pull/30268
 if sys.version_info >= (3, 11):
     NewType = typing.NewType
 else:
@@ -3513,6 +3567,7 @@ else:
         def __reduce__(self):
             return self.__qualname__
 
+        # Breakpoint: https://github.com/python/cpython/pull/21515
         if sys.version_info >= (3, 10):
             # PEP 604 methods
             # It doesn't make sense to have these methods on Python <3.10
@@ -3524,10 +3579,12 @@ else:
                 return typing.Union[other, self]
 
 
+# Breakpoint: https://github.com/python/cpython/pull/124795
 if sys.version_info >= (3, 14):
     TypeAliasType = typing.TypeAliasType
 # <=3.13
 else:
+    # Breakpoint: https://github.com/python/cpython/pull/103764
     if sys.version_info >= (3, 12):
         # 3.12-3.13
         def _is_unionable(obj):
@@ -3723,6 +3780,7 @@ else:
         def __call__(self):
             raise TypeError("Type alias is not callable")
 
+        # Breakpoint: https://github.com/python/cpython/pull/21515
         if sys.version_info >= (3, 10):
             def __or__(self, right):
                 # For forward compatibility with 3.12, reject Unions
@@ -3835,15 +3893,19 @@ if _CapsuleType is not None:
     __all__.append("CapsuleType")
 
 
-if sys.version_info >= (3,14):
+if sys.version_info >= (3, 14):
     from annotationlib import Format, get_annotations
 else:
+    # Available since Python 3.14.0a3
+    # PR: https://github.com/python/cpython/pull/124415
     class Format(enum.IntEnum):
         VALUE = 1
         VALUE_WITH_FAKE_GLOBALS = 2
         FORWARDREF = 3
         STRING = 4
 
+    # Available since Python 3.14.0a1
+    # PR: https://github.com/python/cpython/pull/119891
     def get_annotations(obj, *, globals=None, locals=None, eval_str=False,
                         format=Format.VALUE):
         """Compute the annotations dict for an object.
@@ -4031,23 +4093,13 @@ else:
             # as a way of emulating annotation scopes when calling `eval()`
             type_params = getattr(owner, "__type_params__", None)
 
-        # type parameters require some special handling,
-        # as they exist in their own scope
-        # but `eval()` does not have a dedicated parameter for that scope.
-        # For classes, names in type parameter scopes should override
-        # names in the global scope (which here are called `localns`!),
-        # but should in turn be overridden by names in the class scope
-        # (which here are called `globalns`!)
+        # Type parameters exist in their own scope, which is logically
+        # between the locals and the globals. We simulate this by adding
+        # them to the globals.
         if type_params is not None:
             globals = dict(globals)
-            locals = dict(locals)
             for param in type_params:
-                param_name = param.__name__
-                if (
-                    _FORWARD_REF_HAS_CLASS and not forward_ref.__forward_is_class__
-                ) or param_name not in globals:
-                    globals[param_name] = param
-                    locals.pop(param_name, None)
+                globals[param.__name__] = param
 
         arg = forward_ref.__forward_arg__
         if arg.isidentifier() and not keyword.iskeyword(arg):
@@ -4181,6 +4233,7 @@ class Sentinel:
         def __call__(self, *args, **kwargs):
             raise TypeError(f"{type(self).__name__!r} object is not callable")
 
+    # Breakpoint: https://github.com/python/cpython/pull/21515
     if sys.version_info >= (3, 10):
         def __or__(self, other):
             return typing.Union[self, other]
@@ -4192,6 +4245,26 @@ class Sentinel:
         raise TypeError(f"Cannot pickle {type(self).__name__!r} object")
 
 
+if sys.version_info >= (3, 14, 0, "beta"):
+    type_repr = annotationlib.type_repr
+else:
+    def type_repr(value):
+        """Convert a Python value to a format suitable for use with the STRING format.
+
+        This is intended as a helper for tools that support the STRING format but do
+        not have access to the code that originally produced the annotations. It uses
+        repr() for most objects.
+
+        """
+        if isinstance(value, (type, _types.FunctionType, _types.BuiltinFunctionType)):
+            if value.__module__ == "builtins":
+                return value.__qualname__
+            return f"{value.__module__}.{value.__qualname__}"
+        if value is ...:
+            return "..."
+        return repr(value)
+
+
 # Aliases for items that are in typing in all supported versions.
 # We use hasattr() checks so this library will continue to import on
 # future versions of Python that may remove these names.
diff --git a/server/libs/voluptuous-0.15.2.dist-info/INSTALLER b/server/libs/voluptuous-0.15.2.dist-info/INSTALLER
index a1b589e..5c69047 100644
--- a/server/libs/voluptuous-0.15.2.dist-info/INSTALLER
+++ b/server/libs/voluptuous-0.15.2.dist-info/INSTALLER
@@ -1 +1 @@
-pip
+uv
\ No newline at end of file
diff --git a/server/libs/voluptuous-0.15.2.dist-info/RECORD b/server/libs/voluptuous-0.15.2.dist-info/RECORD
index 07b7692..1090486 100644
--- a/server/libs/voluptuous-0.15.2.dist-info/RECORD
+++ b/server/libs/voluptuous-0.15.2.dist-info/RECORD
@@ -1,17 +1,11 @@
 voluptuous-0.15.2.dist-info/COPYING,sha256=JHtJdren-k2J2Vh8qlCVVh60bcVFfyJ59ipitUUq3qk,1486
-voluptuous-0.15.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+voluptuous-0.15.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
 voluptuous-0.15.2.dist-info/METADATA,sha256=skO8Rp2Rq3VpxIPpE5LWhWiiWWXWHf9HL_-TFOkEz60,20641
 voluptuous-0.15.2.dist-info/RECORD,,
 voluptuous-0.15.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
 voluptuous-0.15.2.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
 voluptuous-0.15.2.dist-info/top_level.txt,sha256=TTdVb7M-vndb67UqTmAxuVjpAUakrlAWJYqvo3w4Iqc,11
 voluptuous/__init__.py,sha256=6_S65O_9lnoewl5dQSLIz_BKrsfxmOK-lG_i3Djd8Z8,2227
-voluptuous/__pycache__/__init__.cpython-311.pyc,,
-voluptuous/__pycache__/error.cpython-311.pyc,,
-voluptuous/__pycache__/humanize.cpython-311.pyc,,
-voluptuous/__pycache__/schema_builder.cpython-311.pyc,,
-voluptuous/__pycache__/util.cpython-311.pyc,,
-voluptuous/__pycache__/validators.cpython-311.pyc,,
 voluptuous/error.py,sha256=qipmadJhLycX4zIju6j8T8rjJHiiELVDv3CSoBCDnwM,4606
 voluptuous/humanize.py,sha256=CWBrrE6fK73iOM19w1CK9_f_Qrc92u2PQIjngG8-EC0,1905
 voluptuous/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
diff --git a/server/libs/zipp-3.23.0.dist-info/INSTALLER b/server/libs/zipp-3.23.0.dist-info/INSTALLER
deleted file mode 100644
index a1b589e..0000000
--- a/server/libs/zipp-3.23.0.dist-info/INSTALLER
+++ /dev/null
@@ -1 +0,0 @@
-pip
diff --git a/server/libs/zipp-3.23.0.dist-info/RECORD b/server/libs/zipp-3.23.0.dist-info/RECORD
deleted file mode 100644
index 08332d5..0000000
--- a/server/libs/zipp-3.23.0.dist-info/RECORD
+++ /dev/null
@@ -1,21 +0,0 @@
-zipp-3.23.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-zipp-3.23.0.dist-info/METADATA,sha256=vdZ9TRbPC_O4k-fRjNPS13StuC837Zhbx3cMYHIms1s,3563
-zipp-3.23.0.dist-info/RECORD,,
-zipp-3.23.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-zipp-3.23.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
-zipp-3.23.0.dist-info/licenses/LICENSE,sha256=WlfLTbheKi3YjCkGKJCK3VfjRRRJ4KmnH9-zh3b9dZ0,1076
-zipp-3.23.0.dist-info/top_level.txt,sha256=iAbdoSHfaGqBfVb2XuR9JqSQHCoOsOtG6y9C_LSpqFw,5
-zipp/__init__.py,sha256=ieXh9GIMdABjKRX_JUJtP9k5wdBLK4Mt5X4nszSkmYE,11976
-zipp/__pycache__/__init__.cpython-311.pyc,,
-zipp/__pycache__/_functools.cpython-311.pyc,,
-zipp/__pycache__/glob.cpython-311.pyc,,
-zipp/_functools.py,sha256=f6Kt9LxZ4TE-cY1lJVdXSId3memSXmH9IdgMbU-_x2k,575
-zipp/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-zipp/compat/__pycache__/__init__.cpython-311.pyc,,
-zipp/compat/__pycache__/overlay.cpython-311.pyc,,
-zipp/compat/__pycache__/py310.cpython-311.pyc,,
-zipp/compat/__pycache__/py313.cpython-311.pyc,,
-zipp/compat/overlay.py,sha256=oEIGAnbr8yGjuKTrVSO2ByewPui71uppbX18BLnYTKE,783
-zipp/compat/py310.py,sha256=S7i6N9mToEn3asNb2ILyjnzvITOXrATD_J4emjyBbDU,256
-zipp/compat/py313.py,sha256=RndvDNtuY7H2D9ecnnzcPBMZ8mZc42gmXD_IwQAXXAE,654
-zipp/glob.py,sha256=DLV9LBsDxA6YVW82e3-tkoNrus1h4R-j3BR6VqS0AzE,3382
diff --git a/server/libs/zipp-4.1.0.dist-info/INSTALLER b/server/libs/zipp-4.1.0.dist-info/INSTALLER
new file mode 100644
index 0000000..5c69047
--- /dev/null
+++ b/server/libs/zipp-4.1.0.dist-info/INSTALLER
@@ -0,0 +1 @@
+uv
\ No newline at end of file
diff --git a/server/libs/zipp-3.23.0.dist-info/METADATA b/server/libs/zipp-4.1.0.dist-info/METADATA
similarity index 90%
rename from server/libs/zipp-3.23.0.dist-info/METADATA
rename to server/libs/zipp-4.1.0.dist-info/METADATA
index 6420117..5e90809 100644
--- a/server/libs/zipp-3.23.0.dist-info/METADATA
+++ b/server/libs/zipp-4.1.0.dist-info/METADATA
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: zipp
-Version: 3.23.0
+Version: 4.1.0
 Summary: Backport of pathlib-compatible object wrapper for zip files
 Author-email: "Jason R. Coombs" 
 License-Expression: MIT
@@ -9,7 +9,7 @@ Classifier: Development Status :: 5 - Production/Stable
 Classifier: Intended Audience :: Developers
 Classifier: Programming Language :: Python :: 3
 Classifier: Programming Language :: Python :: 3 :: Only
-Requires-Python: >=3.9
+Requires-Python: >=3.10
 Description-Content-Type: text/x-rst
 License-File: LICENSE
 Provides-Extra: test
@@ -28,14 +28,14 @@ Requires-Dist: furo; extra == "doc"
 Requires-Dist: sphinx-lint; extra == "doc"
 Requires-Dist: jaraco.tidelift>=1.4; extra == "doc"
 Provides-Extra: check
-Requires-Dist: pytest-checkdocs>=2.4; extra == "check"
+Requires-Dist: pytest-checkdocs>=2.14; extra == "check"
 Requires-Dist: pytest-ruff>=0.2.1; sys_platform != "cygwin" and extra == "check"
 Provides-Extra: cover
 Requires-Dist: pytest-cov; extra == "cover"
 Provides-Extra: enabler
-Requires-Dist: pytest-enabler>=2.2; extra == "enabler"
+Requires-Dist: pytest-enabler>=3.4; extra == "enabler"
 Provides-Extra: type
-Requires-Dist: pytest-mypy; extra == "type"
+Requires-Dist: pytest-mypy>=1.0.1; platform_python_implementation != "PyPy" and extra == "type"
 Dynamic: license-file
 
 .. image:: https://img.shields.io/pypi/v/zipp.svg
@@ -54,7 +54,7 @@ Dynamic: license-file
 .. image:: https://readthedocs.org/projects/zipp/badge/?version=latest
 ..    :target: https://zipp.readthedocs.io/en/latest/?badge=latest
 
-.. image:: https://img.shields.io/badge/skeleton-2025-informational
+.. image:: https://img.shields.io/badge/skeleton-2026-informational
    :target: https://blog.jaraco.com/skeleton
 
 .. image:: https://tidelift.com/badges/package/pypi/zipp
@@ -77,6 +77,8 @@ were contributed to different versions in the standard library:
 
    * - zipp
      - stdlib
+   * - 3.21
+     - 3.15
    * - 3.18
      - 3.13
    * - 3.16
diff --git a/server/libs/zipp-4.1.0.dist-info/RECORD b/server/libs/zipp-4.1.0.dist-info/RECORD
new file mode 100644
index 0000000..7230529
--- /dev/null
+++ b/server/libs/zipp-4.1.0.dist-info/RECORD
@@ -0,0 +1,14 @@
+zipp-4.1.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
+zipp-4.1.0.dist-info/METADATA,sha256=oIaY0N1QkyWFP-oMX3u-_1BG8WzxZm_PDgPQpglMidc,3640
+zipp-4.1.0.dist-info/RECORD,,
+zipp-4.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+zipp-4.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
+zipp-4.1.0.dist-info/licenses/LICENSE,sha256=l1WhhRlmbl8PTK49qtPXASvK5IpgCzEjfXXp_hNOZoM,1076
+zipp-4.1.0.dist-info/top_level.txt,sha256=iAbdoSHfaGqBfVb2XuR9JqSQHCoOsOtG6y9C_LSpqFw,5
+zipp/__init__.py,sha256=bnsTWc5V4UfSkXK9C0ibwWYOlr3gCoToMbh7hwSU1kc,11748
+zipp/_functools.py,sha256=LZrqt6bu0I4bxxAbDsNs07fb5ad5_INdG1gzSdhTLv8,789
+zipp/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+zipp/compat/overlay.py,sha256=oEIGAnbr8yGjuKTrVSO2ByewPui71uppbX18BLnYTKE,783
+zipp/compat/py310.py,sha256=S7i6N9mToEn3asNb2ILyjnzvITOXrATD_J4emjyBbDU,256
+zipp/compat/py313.py,sha256=RndvDNtuY7H2D9ecnnzcPBMZ8mZc42gmXD_IwQAXXAE,654
+zipp/glob.py,sha256=K0GDSIoBYXeO1KBAeiCqfB2uZAP34FhLRY3X2R7wiDA,3395
diff --git a/server/libs/zipp-4.1.0.dist-info/REQUESTED b/server/libs/zipp-4.1.0.dist-info/REQUESTED
new file mode 100644
index 0000000..e69de29
diff --git a/server/libs/zipp-3.23.0.dist-info/WHEEL b/server/libs/zipp-4.1.0.dist-info/WHEEL
similarity index 65%
rename from server/libs/zipp-3.23.0.dist-info/WHEEL
rename to server/libs/zipp-4.1.0.dist-info/WHEEL
index e7fa31b..14a883f 100644
--- a/server/libs/zipp-3.23.0.dist-info/WHEEL
+++ b/server/libs/zipp-4.1.0.dist-info/WHEEL
@@ -1,5 +1,5 @@
 Wheel-Version: 1.0
-Generator: setuptools (80.9.0)
+Generator: setuptools (82.0.1)
 Root-Is-Purelib: true
 Tag: py3-none-any
 
diff --git a/server/libs/zipp-3.23.0.dist-info/licenses/LICENSE b/server/libs/zipp-4.1.0.dist-info/licenses/LICENSE
similarity index 96%
rename from server/libs/zipp-3.23.0.dist-info/licenses/LICENSE
rename to server/libs/zipp-4.1.0.dist-info/licenses/LICENSE
index f60bd57..c891f41 100644
--- a/server/libs/zipp-3.23.0.dist-info/licenses/LICENSE
+++ b/server/libs/zipp-4.1.0.dist-info/licenses/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2025 
+Copyright (c) 2026 
 
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
 associated documentation files (the "Software"), to deal in the Software without restriction, including
diff --git a/server/libs/zipp-3.23.0.dist-info/top_level.txt b/server/libs/zipp-4.1.0.dist-info/top_level.txt
similarity index 100%
rename from server/libs/zipp-3.23.0.dist-info/top_level.txt
rename to server/libs/zipp-4.1.0.dist-info/top_level.txt
diff --git a/server/libs/zipp/__init__.py b/server/libs/zipp/__init__.py
index ed5b214..99ae3ea 100644
--- a/server/libs/zipp/__init__.py
+++ b/server/libs/zipp/__init__.py
@@ -14,10 +14,9 @@ import pathlib
 import posixpath
 import re
 import stat
-import sys
 import zipfile
 
-from ._functools import save_method_args
+from ._functools import none_as, save_method_args
 from .compat.py310 import text_encoding
 from .glob import Translator
 
@@ -196,12 +195,7 @@ class FastLookup(CompleteDirs):
 
 
 def _extract_text_encoding(encoding=None, *args, **kwargs):
-    # compute stack level so that the caller of the caller sees any warning.
-    is_pypy = sys.implementation.name == 'pypy'
-    # PyPy no longer special cased after 7.3.19 (or maybe 7.3.18)
-    # See jaraco/zipp#143
-    is_old_pypi = is_pypy and sys.pypy_version_info < (7, 3, 19)
-    stack_level = 3 + is_old_pypi
+    stack_level = 3
     return text_encoding(encoding, stack_level), args, kwargs
 
 
@@ -277,7 +271,7 @@ class Path:
     resolve to the zipfile.
 
     >>> str(path)
-    'mem/abcde.zip/'
+    'mem/abcde.zip'
     >>> path.name
     'abcde.zip'
     >>> path.filename == pathlib.Path('mem/abcde.zip')
@@ -404,7 +398,7 @@ class Path:
 
     def iterdir(self):
         if not self.is_dir():
-            raise ValueError("Can't listdir a file")
+            raise NotADirectoryError("Can't listdir a file")
         subs = map(self._next, self.root.namelist())
         return filter(self._is_child, subs)
 
@@ -435,7 +429,8 @@ class Path:
         return posixpath.relpath(str(self), str(other.joinpath(*extra)))
 
     def __str__(self):
-        return posixpath.join(self.root.filename, self.at)
+        root = none_as(self.root.filename, ':zipfile:')
+        return posixpath.join(root, self.at) if self.at else root
 
     def __repr__(self):
         return self.__repr.format(self=self)
diff --git a/server/libs/zipp/_functools.py b/server/libs/zipp/_functools.py
index 7390be2..7d82636 100644
--- a/server/libs/zipp/_functools.py
+++ b/server/libs/zipp/_functools.py
@@ -18,3 +18,14 @@ def save_method_args(method):
         return method(self, *args, **kwargs)
 
     return wrapper
+
+
+# from jaraco.functools 4.3
+def none_as(value, replacement=None):
+    """
+    >>> none_as(None, 'foo')
+    'foo'
+    >>> none_as('bar', 'foo')
+    'bar'
+    """
+    return replacement if value is None else value
diff --git a/server/libs/zipp/glob.py b/server/libs/zipp/glob.py
index 1b4ffb3..bf606d7 100644
--- a/server/libs/zipp/glob.py
+++ b/server/libs/zipp/glob.py
@@ -71,7 +71,8 @@ class Translator:
         Perform the replacements for a match from :func:`separate`.
         """
         return match.group('set') or (
-            re.escape(match.group(0))
+            re
+            .escape(match.group(0))
             .replace('\\*\\*', r'.*')
             .replace('\\*', rf'[^{re.escape(self.seps)}]*')
             .replace('\\?', r'[^/]')