From 41f331d4c4028a833620a3ef71e3c4832c82926c Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 18 Jun 2026 17:21:58 +0200 Subject: [PATCH] add updates libs --- .../REQUESTED => .lock} | 0 server/libs/attr/__init__.pyi | 10 +- server/libs/attr/_compat.py | 11 +- server/libs/attr/_funcs.py | 61 +- server/libs/attr/_make.py | 509 ++++-- server/libs/attr/_next_gen.py | 65 +- server/libs/attr/_version_info.py | 3 + server/libs/attr/exceptions.py | 8 +- server/libs/attr/validators.py | 70 +- server/libs/attr/validators.pyi | 66 +- server/libs/attrs-25.3.0.dist-info/INSTALLER | 1 - server/libs/attrs-25.3.0.dist-info/RECORD | 56 - server/libs/attrs-26.1.0.dist-info/INSTALLER | 1 + .../METADATA | 87 +- server/libs/attrs-26.1.0.dist-info/RECORD | 37 + .../REQUESTED | 0 .../WHEEL | 2 +- .../licenses/LICENSE | 0 server/libs/attrs/__init__.py | 5 +- server/libs/attrs/__init__.pyi | 65 +- server/libs/bin/tclfmt.exe | Bin 108435 -> 47104 bytes server/libs/bin/tclint.exe | Bin 108435 -> 47104 bytes server/libs/bin/tclsp.exe | Bin 108434 -> 47104 bytes server/libs/cattrs-25.1.1.dist-info/INSTALLER | 1 - server/libs/cattrs-25.1.1.dist-info/RECORD | 102 -- server/libs/cattrs-26.1.0.dist-info/INSTALLER | 1 + .../METADATA | 15 +- server/libs/cattrs-26.1.0.dist-info/RECORD | 58 + .../REQUESTED | 0 .../WHEEL | 2 +- .../licenses/LICENSE | 0 server/libs/cattrs/_compat.py | 86 +- server/libs/cattrs/_generics.py | 4 +- server/libs/cattrs/cols.py | 64 +- server/libs/cattrs/converters.py | 47 +- server/libs/cattrs/disambiguators.py | 3 +- server/libs/cattrs/enums.py | 36 + server/libs/cattrs/errors.py | 26 +- server/libs/cattrs/gen/__init__.py | 132 +- server/libs/cattrs/gen/_shared.py | 18 +- server/libs/cattrs/gen/typeddicts.py | 96 +- server/libs/cattrs/preconf/__init__.py | 9 +- server/libs/cattrs/preconf/bson.py | 4 +- server/libs/cattrs/preconf/cbor2.py | 2 +- server/libs/cattrs/preconf/json.py | 2 +- server/libs/cattrs/preconf/msgpack.py | 2 +- server/libs/cattrs/preconf/msgspec.py | 17 +- server/libs/cattrs/preconf/orjson.py | 4 +- server/libs/cattrs/preconf/tomlkit.py | 12 +- server/libs/cattrs/preconf/tomllib.py | 94 ++ server/libs/cattrs/preconf/ujson.py | 2 +- server/libs/cattrs/strategies/_subclasses.py | 27 +- server/libs/cattrs/strategies/_unions.py | 110 +- server/libs/cattrs/subclasses.py | 27 + server/libs/exceptiongroup.py | 19 + .../INSTALLER | 2 +- .../importlib_metadata-6.8.0.dist-info/RECORD | 11 +- .../lsprotocol-2023.0.1.dist-info/INSTALLER | 2 +- .../libs/lsprotocol-2023.0.1.dist-info/RECORD | 7 +- .../libs/packaging-25.0.dist-info/INSTALLER | 1 - server/libs/packaging-25.0.dist-info/RECORD | 41 - .../libs/packaging-26.2.dist-info/INSTALLER | 1 + .../METADATA | 19 +- server/libs/packaging-26.2.dist-info/RECORD | 29 + .../REQUESTED | 0 .../WHEEL | 0 .../licenses/LICENSE | 0 .../licenses/LICENSE.APACHE | 0 .../licenses/LICENSE.BSD | 0 server/libs/packaging/__init__.py | 2 +- server/libs/packaging/_elffile.py | 1 - server/libs/packaging/_manylinux.py | 72 +- server/libs/packaging/_musllinux.py | 2 +- server/libs/packaging/_parser.py | 60 +- server/libs/packaging/_structures.py | 62 +- server/libs/packaging/_tokenizer.py | 50 +- server/libs/packaging/dependency_groups.py | 302 ++++ server/libs/packaging/direct_url.py | 325 ++++ server/libs/packaging/errors.py | 94 ++ server/libs/packaging/licenses/__init__.py | 63 +- server/libs/packaging/licenses/_spdx.py | 42 +- server/libs/packaging/markers.py | 240 ++- server/libs/packaging/metadata.py | 240 ++- server/libs/packaging/pylock.py | 905 +++++++++++ server/libs/packaging/requirements.py | 58 +- server/libs/packaging/specifiers.py | 1410 ++++++++++++++--- server/libs/packaging/tags.py | 398 ++++- server/libs/packaging/utils.py | 187 ++- server/libs/packaging/version.py | 957 +++++++++-- .../libs/pathspec-0.11.2.dist-info/INSTALLER | 2 +- server/libs/pathspec-0.11.2.dist-info/RECORD | 10 +- server/libs/ply-3.11.dist-info/INSTALLER | 2 +- server/libs/ply-3.11.dist-info/RECORD | 8 +- server/libs/pygls-1.3.1.dist-info/INSTALLER | 2 +- server/libs/pygls-1.3.1.dist-info/RECORD | 21 +- server/libs/tclint-0.6.0.dist-info/INSTALLER | 1 - server/libs/tclint-0.6.0.dist-info/RECORD | 51 - server/libs/tclint-0.8.0.dist-info/INSTALLER | 1 + .../METADATA | 25 +- server/libs/tclint-0.8.0.dist-info/RECORD | 34 + .../REQUESTED | 0 .../WHEEL | 2 +- .../entry_points.txt | 3 + .../licenses/LICENSE | 0 .../top_level.txt | 0 server/libs/tclint/__main__.py | 1 + server/libs/tclint/_version.py | 19 +- server/libs/tclint/checks.py | 57 +- server/libs/tclint/cli/resolver.py | 98 ++ server/libs/tclint/cli/tclfmt.py | 114 +- server/libs/tclint/cli/tclint.py | 82 +- server/libs/tclint/cli/tclsp.py | 210 ++- server/libs/tclint/cli/utils.py | 98 +- server/libs/tclint/commands/__init__.py | 37 +- server/libs/tclint/commands/builtin.py | 350 ++-- server/libs/tclint/commands/checks.py | 361 +++-- server/libs/tclint/commands/plugins.py | 109 +- server/libs/tclint/commands/schema.py | 10 +- server/libs/tclint/comments.py | 17 +- server/libs/tclint/config.py | 649 ++++---- server/libs/tclint/format.py | 269 +++- server/libs/tclint/lexer.py | 5 +- server/libs/tclint/parser.py | 98 +- server/libs/tclint/plugins/expect.py | 64 + server/libs/tclint/symbol_table.py | 45 + server/libs/tclint/syntax_tree.py | 46 +- server/libs/tclint/violations.py | 6 +- .../INSTALLER | 1 - .../typing_extensions-4.14.1.dist-info/RECORD | 8 - .../INSTALLER | 1 + .../METADATA | 8 +- .../typing_extensions-4.15.0.dist-info/RECORD | 7 + .../REQUESTED | 0 .../WHEEL | 0 .../licenses/LICENSE | 0 server/libs/typing_extensions.py | 109 +- .../voluptuous-0.15.2.dist-info/INSTALLER | 2 +- .../libs/voluptuous-0.15.2.dist-info/RECORD | 8 +- server/libs/zipp-3.23.0.dist-info/INSTALLER | 1 - server/libs/zipp-3.23.0.dist-info/RECORD | 21 - server/libs/zipp-4.1.0.dist-info/INSTALLER | 1 + .../METADATA | 14 +- server/libs/zipp-4.1.0.dist-info/RECORD | 14 + server/libs/zipp-4.1.0.dist-info/REQUESTED | 0 .../WHEEL | 2 +- .../licenses/LICENSE | 2 +- .../top_level.txt | 0 server/libs/zipp/__init__.py | 17 +- server/libs/zipp/_functools.py | 11 + server/libs/zipp/glob.py | 3 +- 150 files changed, 8249 insertions(+), 2550 deletions(-) rename server/libs/{attrs-25.3.0.dist-info/REQUESTED => .lock} (100%) delete mode 100644 server/libs/attrs-25.3.0.dist-info/INSTALLER delete mode 100644 server/libs/attrs-25.3.0.dist-info/RECORD create mode 100644 server/libs/attrs-26.1.0.dist-info/INSTALLER rename server/libs/{attrs-25.3.0.dist-info => attrs-26.1.0.dist-info}/METADATA (61%) create mode 100644 server/libs/attrs-26.1.0.dist-info/RECORD rename server/libs/{cattrs-25.1.1.dist-info => attrs-26.1.0.dist-info}/REQUESTED (100%) rename server/libs/{cattrs-25.1.1.dist-info => attrs-26.1.0.dist-info}/WHEEL (67%) rename server/libs/{attrs-25.3.0.dist-info => attrs-26.1.0.dist-info}/licenses/LICENSE (100%) delete mode 100644 server/libs/cattrs-25.1.1.dist-info/INSTALLER delete mode 100644 server/libs/cattrs-25.1.1.dist-info/RECORD create mode 100644 server/libs/cattrs-26.1.0.dist-info/INSTALLER rename server/libs/{cattrs-25.1.1.dist-info => cattrs-26.1.0.dist-info}/METADATA (95%) create mode 100644 server/libs/cattrs-26.1.0.dist-info/RECORD rename server/libs/{packaging-25.0.dist-info => cattrs-26.1.0.dist-info}/REQUESTED (100%) rename server/libs/{attrs-25.3.0.dist-info => cattrs-26.1.0.dist-info}/WHEEL (67%) rename server/libs/{cattrs-25.1.1.dist-info => cattrs-26.1.0.dist-info}/licenses/LICENSE (100%) create mode 100644 server/libs/cattrs/enums.py create mode 100644 server/libs/cattrs/preconf/tomllib.py create mode 100644 server/libs/cattrs/subclasses.py create mode 100644 server/libs/exceptiongroup.py delete mode 100644 server/libs/packaging-25.0.dist-info/INSTALLER delete mode 100644 server/libs/packaging-25.0.dist-info/RECORD create mode 100644 server/libs/packaging-26.2.dist-info/INSTALLER rename server/libs/{packaging-25.0.dist-info => packaging-26.2.dist-info}/METADATA (85%) create mode 100644 server/libs/packaging-26.2.dist-info/RECORD rename server/libs/{tclint-0.6.0.dist-info => packaging-26.2.dist-info}/REQUESTED (100%) rename server/libs/{packaging-25.0.dist-info => packaging-26.2.dist-info}/WHEEL (100%) rename server/libs/{packaging-25.0.dist-info => packaging-26.2.dist-info}/licenses/LICENSE (100%) rename server/libs/{packaging-25.0.dist-info => packaging-26.2.dist-info}/licenses/LICENSE.APACHE (100%) rename server/libs/{packaging-25.0.dist-info => packaging-26.2.dist-info}/licenses/LICENSE.BSD (100%) create mode 100644 server/libs/packaging/dependency_groups.py create mode 100644 server/libs/packaging/direct_url.py create mode 100644 server/libs/packaging/errors.py create mode 100644 server/libs/packaging/pylock.py delete mode 100644 server/libs/tclint-0.6.0.dist-info/INSTALLER delete mode 100644 server/libs/tclint-0.6.0.dist-info/RECORD create mode 100644 server/libs/tclint-0.8.0.dist-info/INSTALLER rename server/libs/{tclint-0.6.0.dist-info => tclint-0.8.0.dist-info}/METADATA (83%) create mode 100644 server/libs/tclint-0.8.0.dist-info/RECORD rename server/libs/{typing_extensions-4.14.1.dist-info => tclint-0.8.0.dist-info}/REQUESTED (100%) rename server/libs/{tclint-0.6.0.dist-info => tclint-0.8.0.dist-info}/WHEEL (65%) rename server/libs/{tclint-0.6.0.dist-info => tclint-0.8.0.dist-info}/entry_points.txt (69%) rename server/libs/{tclint-0.6.0.dist-info => tclint-0.8.0.dist-info}/licenses/LICENSE (100%) rename server/libs/{tclint-0.6.0.dist-info => tclint-0.8.0.dist-info}/top_level.txt (100%) create mode 100644 server/libs/tclint/cli/resolver.py create mode 100644 server/libs/tclint/plugins/expect.py create mode 100644 server/libs/tclint/symbol_table.py delete mode 100644 server/libs/typing_extensions-4.14.1.dist-info/INSTALLER delete mode 100644 server/libs/typing_extensions-4.14.1.dist-info/RECORD create mode 100644 server/libs/typing_extensions-4.15.0.dist-info/INSTALLER rename server/libs/{typing_extensions-4.14.1.dist-info => typing_extensions-4.15.0.dist-info}/METADATA (89%) create mode 100644 server/libs/typing_extensions-4.15.0.dist-info/RECORD rename server/libs/{zipp-3.23.0.dist-info => typing_extensions-4.15.0.dist-info}/REQUESTED (100%) rename server/libs/{typing_extensions-4.14.1.dist-info => typing_extensions-4.15.0.dist-info}/WHEEL (100%) rename server/libs/{typing_extensions-4.14.1.dist-info => typing_extensions-4.15.0.dist-info}/licenses/LICENSE (100%) delete mode 100644 server/libs/zipp-3.23.0.dist-info/INSTALLER delete mode 100644 server/libs/zipp-3.23.0.dist-info/RECORD create mode 100644 server/libs/zipp-4.1.0.dist-info/INSTALLER rename server/libs/{zipp-3.23.0.dist-info => zipp-4.1.0.dist-info}/METADATA (90%) create mode 100644 server/libs/zipp-4.1.0.dist-info/RECORD create mode 100644 server/libs/zipp-4.1.0.dist-info/REQUESTED rename server/libs/{zipp-3.23.0.dist-info => zipp-4.1.0.dist-info}/WHEEL (65%) rename server/libs/{zipp-3.23.0.dist-info => zipp-4.1.0.dist-info}/licenses/LICENSE (96%) rename server/libs/{zipp-3.23.0.dist-info => zipp-4.1.0.dist-info}/top_level.txt (100%) 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 3173b085b53035c7579f9efa491d33ebde150c89..6f9a9d3eaeceaf63ba48799ea04eab10ca5e9da2 100644 GIT binary patch literal 47104 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 diff --git a/server/libs/bin/tclint.exe b/server/libs/bin/tclint.exe index 6585f69d2856e35e08f348110794dae92e5dce2e..0e31611d220980360f1fbe53562d6915639c95da 100644 GIT binary patch literal 47104 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 V2GJ|Cc=v{BTPN}3(zR#{{1@RWIWGVJ literal 108435 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 diff --git a/server/libs/bin/tclsp.exe b/server/libs/bin/tclsp.exe index 8fbb54d2564f81ed434a2d040dcc15c45e24ed70..770e2499b9ebcb4df7ed07f5203e154d6ffcd7cc 100644 GIT binary patch literal 47104 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 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'[^/]')