Compare commits

...
6 Commits
Author SHA1 Message Date
Christoph Brandau dfe15dd3db Fix single-line brace formatting to preserve internal whitespace
The formatter now calculates and passes explicit leading and trailing whitespace around expression content to the `_brace` method. This ensures correct rendering of single-line expressions that include braces.
2026-06-22 08:49:22 +02:00
Christoph Brandau 3b233389c8 Fix Tcl formatting bug by adjusting formatter options 2026-06-22 08:29:48 +02:00
Christoph Brandau f60c4563e4 Delegate TCL folding to server, fix bugs, and enhance stability
- Move TCL folding range computation from client to language server, avoiding duplicate regions.
- Serialize language server restarts to prevent multiple server instances running concurrently.
- Pin server-side Python dependencies to specific minor versions for improved stability and predictability.
- Make debug server connection non-fatal, allowing the language server to start even if the debugger isn't attached.
- Optimize semantic token generation by caching Tclint plugin commands.
2026-06-18 20:38:47 +02:00
Christoph Brandau 3ad3847045 add new features and fix bugs 2026-06-18 17:31:51 +02:00
Christoph Brandau 41f331d4c4 add updates libs 2026-06-18 17:21:58 +02:00
Christoph 91ce762570 Update version to 2025.9.200 2025-09-01 13:24:13 +00:00
167 changed files with 9239 additions and 2749 deletions
+1
View File
@@ -9,3 +9,4 @@ __pycache__
.nox
*.g4
.antlr
.claude
+8
View File
@@ -13,3 +13,11 @@
## [0.2.0]
- Add DEF File Support
## [2026.6.100]
- Fix several bugs
## [2026.6.200]
- Fix foramtting bug
+19 -4
View File
@@ -29,7 +29,7 @@ import { loadServerDefaults } from "./common/setup"
import { getLSClientTraceLevel } from "./common/utilities"
import { createOutputChannel, onDidChangeConfiguration, registerCommand } from "./common/vscodeapi"
let client: LanguageClient
let client: LanguageClient | undefined
export async function activate(context: vscode.ExtensionContext) {
// This is required to get server name and module. This should be
@@ -61,7 +61,7 @@ export async function activate(context: vscode.ExtensionContext) {
traceLog(`Module: ${serverInfo.module}`)
traceVerbose(`Full Server Info: ${JSON.stringify(serverInfo)}`)
const runServer = async () => {
const runServerImpl = async () => {
const interpreter = getInterpreterFromSetting(serverId)
if (interpreter && interpreter.length > 0) {
if (checkVersion(await resolveInterpreter(interpreter))) {
@@ -90,6 +90,16 @@ export async function activate(context: vscode.ExtensionContext) {
)
}
// Serialize server (re)starts. Overlapping triggers (interpreter change,
// config change, restart command, initial activation) would otherwise each
// read the stale module-level `client`, start a new server and leave the
// previous one running orphaned -> hints/hover shown multiple times.
let runServerQueue: Promise<void> = Promise.resolve()
const runServer = () => {
runServerQueue = runServerQueue.catch(() => undefined).then(() => runServerImpl())
return runServerQueue
}
context.subscriptions.push(
onDidChangePythonInterpreter(async () => {
await runServer()
@@ -115,6 +125,10 @@ export async function activate(context: vscode.ExtensionContext) {
}
})
// Folding ranges for TCL are provided by the language server
// (folding_range_provider in lsp_server.py). No client-side provider here to
// avoid duplicate folding regions.
//
const formatCdlProvider = vscode.languages.registerDocumentFormattingEditProvider(
{ scheme: "file", language: "cdl" },
@@ -207,6 +221,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(diagnosticCollectionCdl, diagnosticCollectionDef)
// Check if the first line of the CDL file contains "MACHINE"
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((document) => {
if (document.languageId === "cdl" || document.languageId === "def") {
if (document.languageId === "cdl") {
@@ -215,8 +230,7 @@ export async function activate(context: vscode.ExtensionContext) {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
}
})
}),
vscode.workspace.onDidChangeTextDocument((event) => {
const document = event.document
if (document.languageId === "cdl" || document.languageId === "def") {
@@ -227,6 +241,7 @@ export async function activate(context: vscode.ExtensionContext) {
}
}
})
)
}
export function deactivate(): Thenable<void> | undefined {
+7
View File
@@ -3,6 +3,13 @@
// symbol used for single line comment. Remove this entry if your language does not support line comments
"lineComment": "#"
},
"indentationRules": {
// Ignore pure comment lines so they don't terminate folds inside Tcl brace blocks.
"unIndentedLinePattern": "^\\s*#.*$",
// Opening braces on their own line are common in NX Tcl.
"increaseIndentPattern": "^((?!#).)*(\\{[^}\"']*)$",
"decreaseIndentPattern": "^\\s*[\\}\\]\\)].*$"
},
// symbols used as brackets
"brackets": [
["{", "}"],
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nx-post-support",
"version": "0.3.0",
"version": "2025.9.200",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nx-post-support",
"version": "0.3.0",
"version": "2025.9.200",
"devDependencies": {
"@types/vscode": "^1.96.0",
"@vscode/vsce": "^3.2.1",
+4 -2
View File
@@ -2,10 +2,12 @@
"name": "nx-post-support",
"displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with syntax highlighting, formatting, linting, and auto-completion for CDL, TCL, and DEF files",
"version": "2025.9.100",
"version": "2025.9.200",
"publisher": "Christoph",
"icon": "images/nx-1.png",
"extensionDependencies": ["ms-python.python"],
"extensionDependencies": [
"ms-python.python"
],
"serverInfo": {
"name": "NX Postprocessor Support",
"module": "nx-post-support"
+5 -5
View File
@@ -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(
+8 -3
View File
@@ -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:
+45 -16
View File
@@ -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:
+394 -111
View File
@@ -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(
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,
these,
slots,
is_frozen,
weakref_slot,
_determine_whether_to_implement(
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,13 +1910,18 @@ 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."
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)
if attrs is 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 <slotted classes>`.
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 <hashing>` the class is.
added_match_args (bool):
Whether the class supports positional `match <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 <transform-fields>`.
.. 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:
+58 -7
View File
@@ -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
<https://github.com/python-attrs/attrs/issues/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
<https://github.com/python-attrs/attrs/issues/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 <types>`.
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
+3
View File
@@ -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))
+3 -3
View File
@@ -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
"""
def __init__(self):
msg = "can't set attribute"
args: ClassVar[tuple[str]] = [msg]
super().__init__(msg)
self.msg = msg
class FrozenInstanceError(FrozenError):
+53 -13
View File
@@ -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:
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"<deep_mapping validator for objects mapping {self.key_validator!r} to {self.value_validator!r}>"
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
+60 -6
View File
@@ -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]: ...
@@ -1 +0,0 @@
pip
-56
View File
@@ -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
@@ -0,0 +1 @@
uv
@@ -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
<p align="center">
@@ -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'<a href="{sponsor["url"]}"><img title="{sponsor["title"]}" src="https://www.attrs.org/en/25.3.0/_static/sponsors/{sponsor["img"]}" width="190" /></a>')
print(f'<a href="{sponsor["url"]}"><img title="{sponsor["title"]}" src="https://www.attrs.org/en/26.1.0/_static/sponsors/{sponsor["img"]}" width="190" /></a>')
]]] -->
<a href="https://www.variomedia.de/"><img title="Variomedia AG" src="https://www.attrs.org/en/25.3.0/_static/sponsors/Variomedia.svg" width="190" /></a>
<a href="https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek"><img title="Tidelift" src="https://www.attrs.org/en/25.3.0/_static/sponsors/Tidelift.svg" width="190" /></a>
<a href="https://klaviyo.com/"><img title="Klaviyo" src="https://www.attrs.org/en/25.3.0/_static/sponsors/Klaviyo.svg" width="190" /></a>
<a href="https://privacy-solutions.org/"><img title="Privacy Solutions" src="https://www.attrs.org/en/25.3.0/_static/sponsors/Privacy-Solutions.svg" width="190" /></a>
<a href="https://www.emsys-renewables.com/"><img title="emsys renewables" src="https://www.attrs.org/en/25.3.0/_static/sponsors/emsys-renewables.svg" width="190" /></a>
<a href="https://filepreviews.io/"><img title="FilePreviews" src="https://www.attrs.org/en/25.3.0/_static/sponsors/FilePreviews.svg" width="190" /></a>
<a href="https://polar.sh/"><img title="Polar" src="https://www.attrs.org/en/25.3.0/_static/sponsors/Polar.svg" width="190" /></a>
<a href="https://www.variomedia.de/"><img title="Variomedia AG" src="https://www.attrs.org/en/26.1.0/_static/sponsors/Variomedia.svg" width="190" /></a>
<a href="https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek"><img title="Tidelift" src="https://www.attrs.org/en/26.1.0/_static/sponsors/Tidelift.svg" width="190" /></a>
<a href="https://kraken.tech/"><img title="Kraken Tech" src="https://www.attrs.org/en/26.1.0/_static/sponsors/Kraken.svg" width="190" /></a>
<a href="https://privacy-solutions.org/"><img title="Privacy Solutions" src="https://www.attrs.org/en/26.1.0/_static/sponsors/Privacy-Solutions.svg" width="190" /></a>
<a href="https://filepreviews.io/"><img title="FilePreviews" src="https://www.attrs.org/en/26.1.0/_static/sponsors/FilePreviews.svg" width="190" /></a>
<a href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=structlog"><img title="TestMu AI" src="https://www.attrs.org/en/26.1.0/_static/sponsors/TestMu-AI.svg" width="190" /></a>
<a href="https://polar.sh/"><img title="Polar" src="https://www.attrs.org/en/26.1.0/_static/sponsors/Polar.svg" width="190" /></a>
<!-- [[[end]]] -->
</p>
@@ -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)
+37
View File
@@ -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
@@ -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
+4 -1
View File
@@ -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",
+58 -7
View File
@@ -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: ...
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
pip
-102
View File
@@ -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
@@ -0,0 +1 @@
uv
@@ -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
@@ -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
@@ -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
+43 -41
View File
@@ -29,7 +29,6 @@ from typing import (
_AnnotatedAlias,
_GenericAlias,
_SpecialGenericAlias,
_UnionGenericAlias,
get_args,
get_origin,
get_type_hints,
@@ -209,9 +208,6 @@ 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)
@@ -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.<locals>.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
)
+2 -2
View File
@@ -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):
+54 -10
View File
@@ -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()
]
+34 -13
View File
@@ -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(
+1 -2
View File
@@ -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,
+36
View File
@@ -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)
+16 -10
View File
@@ -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))}"
)
+99 -15
View File
@@ -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]]:
@@ -96,6 +96,16 @@ def make_dict_unstructure_fn_from_attrs(
will be included.
.. 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,15 +192,31 @@ 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}:")
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:
@@ -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]]:
@@ -237,11 +275,20 @@ def make_dict_unstructure_fn(
.. 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]:
@@ -315,6 +362,12 @@ def make_dict_structure_fn_from_attrs(
will be included.
.. 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]:
@@ -714,6 +792,12 @@ def make_dict_structure_fn(
.. 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 = {}
+17 -1
View File
@@ -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:
+61 -35
View File
@@ -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]:
+2 -7
View File
@@ -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):
+2 -2
View File
@@ -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
)
+1 -1
View File
@@ -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
)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
)
+14 -3
View File
@@ -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))
+2 -2
View File
@@ -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
+9 -3
View File
@@ -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
)
+94
View File
@@ -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
+1 -1
View File
@@ -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
)
+20 -7
View File
@@ -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)
+65 -45
View File
@@ -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))
+27
View File
@@ -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__")
)
]
+19
View File
@@ -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)
@@ -1 +1 @@
pip
uv
@@ -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
@@ -1 +1 @@
pip
uv
@@ -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
@@ -1 +0,0 @@
pip
@@ -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
@@ -0,0 +1 @@
uv
@@ -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 <donald@stufft.io>
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
@@ -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
+1 -1
View File
@@ -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"
-1
View File
@@ -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
"""
+32 -32
View File
@@ -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"):
if version == _GLibCVersion(2, 5) and hasattr(_manylinux, "manylinux1_compatible"):
return bool(_manylinux.manylinux1_compatible)
if version == _GLibCVersion(2, 12):
if hasattr(_manylinux, "manylinux2010_compatible"):
if version == _GLibCVersion(2, 12) and hasattr(
_manylinux, "manylinux2010_compatible"
):
return bool(_manylinux.manylinux2010_compatible)
if version == _GLibCVersion(2, 17):
if hasattr(_manylinux, "manylinux2014_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}"
yield "manylinux_{}_{}_{}".format(*glibc_version, 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):
if legacy_tag := _LEGACY_MANYLINUX_MAP.get(glibc_version):
yield f"{legacy_tag}_{arch}"
+1 -1
View File
@@ -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)
+50 -10
View File
@@ -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
"""
+17 -45
View File
@@ -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()
+24 -26
View File
@@ -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()
+302
View File
@@ -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
+325
View File
@@ -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 '<algorithm>=<hash>')",
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())
+94
View File
@@ -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)
+52 -11
View File
@@ -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(" )", ")"),
)
+41 -1
View File
@@ -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},
+181 -51
View File
@@ -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 <pypug: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
<pypug: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:
"""
Normalize extra values.
"""
if isinstance(results[0], tuple):
lhs, op, rhs = results[0]
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)
results[0] = lhs, op, rhs
return results
return lhs, op, rhs
def _normalize_extra_values(results: MarkerList) -> MarkerList:
"""
Normalize extra values.
"""
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[-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 <pypug: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"<Marker('{self}')>"
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 <pypug: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
+165 -63
View File
@@ -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 .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] = []
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}"
exceptions.append(InvalidMetadata(unparsed_key, message))
if exceptions:
raise ExceptionGroup("unparsed", exceptions)
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)
+905
View File
@@ -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
+47 -9
View File
@@ -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?
@@ -64,21 +83,40 @@ class Requirement:
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"<Requirement('{self}')>"
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
File diff suppressed because it is too large Load Diff
+336 -60
View File
@@ -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<python_version>-<abi>-<platform>
- cp<python_version>-abi3-<platform>
- cp<python_version>-none-<platform>
- cp<less than python_version>-abi3-<platform> # 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<python_version>-<abi>-<platform>``
- ``cp<python_version>-<stable_abi>-<platform>``
- ``cp<python_version>-none-<platform>``
- ``cp<older version>-<stable_abi>-<platform>`` 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}"
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:
- <interpreter>-<abi>-<platform>
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:
- ``<interpreter>-<abi>-<platform>``
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-<platform>
- <interpreter>-none-any # ... if `interpreter` is provided.
- py*-none-any
The specific tags generated are:
- ``py*-none-<platform>``
- ``<interpreter>-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
+158 -25
View File
@@ -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:
if isinstance(version, str):
try:
parsed = Version(version)
version = Version(version)
except InvalidVersion:
# Legacy versions cannot be normalized
return version
return canonicalize_version(parsed, strip_trailing_zero=strip_trailing_zero)
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
<pypug:binary-distribution-format>`.
>>> 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"):
File diff suppressed because it is too large Load Diff
@@ -1 +1 @@
pip
uv
+1 -9
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
pip
uv
+1 -7
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
pip
uv

Some files were not shown because too many files have changed in this diff Show More