diff --git a/Makefile b/Makefile index 737ffb0..bfe0ba5 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,27 @@ -.PHONY: test flakes tags clean release official +.PHONY: test flakes lint format tags clean install sync build release official all: test +install: + @uv sync + +sync: + @uv sync --all-extras + test: - @attest -rquickfix + @uv run attest -rquickfix flakes: - @pyflakes attest + @uv run ruff check attest + +lint: + @uv run ruff check attest + +format: + @uv run ruff format attest + +fix: + @uv run ruff check --fix attest tags: @ctags -R attest @@ -16,9 +31,12 @@ clean: @echo @echo | xargs -p git clean -fdx -release: - @python setup.py release sdist build_sphinx -Ea +build: + @uv build + +release: build + @echo "Built distribution packages" official: - @tox -e ALL - @echo | xargs -p python setup.py upload_docs release sdist upload + @uv run tox -e ALL + @echo "Run 'uv publish' to upload to PyPI" diff --git a/attest/ast.py b/attest/ast.py index d43a351..4e6a7b4 100644 --- a/attest/ast.py +++ b/attest/ast.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ast ~~~ @@ -86,15 +85,13 @@ def literal_eval(node_or_string): dicts, booleans or None. """ _safe_names = {'None': None, 'True': True, 'False': False} - if isinstance(node_or_string, basestring): + if isinstance(node_or_string, str): node_or_string = parse(node_or_string, mode='eval') if isinstance(node_or_string, Expression): node_or_string = node_or_string.body def _convert(node): - if isinstance(node, Str): - return node.s - elif isinstance(node, Num): - return node.n + if isinstance(node, Constant): + return node.value elif isinstance(node, Tuple): return tuple(map(_convert, node.elts)) elif isinstance(node, List): @@ -120,21 +117,21 @@ def dump(node, annotate_fields=True, include_attributes=False): def _format(node): if isinstance(node, AST): fields = [(a, _format(b)) for a, b in iter_fields(node)] - rv = '%s(%s' % (node.__class__.__name__, ', '.join( - ('%s=%s' % field for field in fields) - if annotate_fields else - (b for a, b in fields) - )) + if annotate_fields: + fields_str = ", ".join(f"{a}={b}" for a, b in fields) + else: + fields_str = ", ".join(b for a, b in fields) + rv = f'{node.__class__.__name__}({fields_str}' if include_attributes and node._attributes: rv += fields and ', ' or ' ' - rv += ', '.join('%s=%s' % (a, _format(getattr(node, a))) + rv += ', '.join(f'{a}={_format(getattr(node, a))}' for a in node._attributes) return rv + ')' elif isinstance(node, list): - return '[%s]' % ', '.join(_format(x) for x in node) + return f'[{", ".join(_format(x) for x in node)}]' return repr(node) if not isinstance(node, AST): - raise TypeError('expected AST, got %r' % node.__class__.__name__) + raise TypeError(f'expected AST, got {node.__class__.__name__!r}') return _format(node) @@ -230,10 +227,14 @@ def get_docstring(node, trim=True): will be raised. """ if not isinstance(node, (FunctionDef, ClassDef, Module)): - raise TypeError("%r can't have docstrings" % node.__class__.__name__) - if node.body and isinstance(node.body[0], Expr) and \ - isinstance(node.body[0].value, Str): - doc = node.body[0].value.s + raise TypeError(f"{node.__class__.__name__!r} can't have docstrings") + if ( + node.body + and isinstance(node.body[0], Expr) + and isinstance(node.body[0].value, Constant) + and isinstance(node.body[0].value.value, str) + ): + doc = node.body[0].value.value if trim: doc = trim_docstring(doc) return doc @@ -244,8 +245,8 @@ def trim_docstring(docstring): lines = docstring.expandtabs().splitlines() # Find minimum indentation of any non-blank lines after first line. - from sys import maxint - margin = maxint + from sys import maxsize + margin = maxsize for line in lines[1:]: content = len(line.lstrip()) if content: @@ -255,7 +256,7 @@ def trim_docstring(docstring): # Remove indentation. if lines: lines[0] = lines[0].lstrip() - if margin < maxint: + if margin < maxsize: for i in range(1, len(lines)): lines[i] = lines[i][margin:] @@ -274,7 +275,7 @@ def get_symbol(operator): try: return ALL_SYMBOLS[operator] except KeyError: - raise LookupError('no known symbol for %r' % operator) + raise LookupError(f'no known symbol for {operator!r}') def walk(node): @@ -290,7 +291,7 @@ def walk(node): yield node -class NodeVisitor(object): +class NodeVisitor: """Walks the abstract syntax tree and call visitor functions for every node found. The visitor functions may return values which will be forwarded by the `visit` method. diff --git a/attest/codegen.py b/attest/codegen.py index da0fa7b..be5a539 100644 --- a/attest/codegen.py +++ b/attest/codegen.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ codegen ~~~~~~~ @@ -59,7 +58,7 @@ def write(self, x): def newline(self, node=None, extra=0): self.new_lines = max(self.new_lines, 1 + extra) if node is not None and self.add_line_information: - self.write('# line: %s' % node.lineno) + self.write(f'# line: {node.lineno}') self.new_lines = 1 def body(self, statements): @@ -127,7 +126,7 @@ def visit_AugAssign(self, node): def visit_ImportFrom(self, node): self.newline(node) - self.write('from %s%s import ' % ('.' * node.level, node.module)) + self.write(f'from {"." * node.level}{node.module} import ') for idx, item in enumerate(node.names): if idx: self.write(', ') @@ -147,7 +146,7 @@ def visit_FunctionDef(self, node): self.newline(extra=1) self.decorators(node) self.newline(node) - self.write('def %s(' % node.name) + self.write(f'def {node.name}(') self.signature(node.args) self.write('):') self.body(node.body) @@ -164,7 +163,7 @@ def paren_or_comma(): self.newline(extra=2) self.decorators(node) self.newline(node) - self.write('class %s' % node.name) + self.write(f'class {node.name}') for base in node.bases: paren_or_comma() self.visit(base) @@ -237,23 +236,6 @@ def visit_Pass(self, node): self.newline(node) self.write('pass') - def visit_Print(self, node): - # XXX: python 2.6 only - self.newline(node) - self.write('print ') - want_comma = False - if node.dest is not None: - self.write(' >> ') - self.visit(node.dest) - want_comma = True - for value in node.values: - if want_comma: - self.write(', ') - self.visit(value) - want_comma = True - if not node.nl: - self.write(',') - def visit_Delete(self, node): self.newline(node) self.write('del ') @@ -341,11 +323,11 @@ def write_comma(): write_comma() self.write(keyword.arg + '=') self.visit(keyword.value) - if node.starargs is not None: + if hasattr(node, 'starargs') and node.starargs is not None: write_comma() self.write('*') self.visit(node.starargs) - if node.kwargs is not None: + if hasattr(node, 'kwargs') and node.kwargs is not None: write_comma() self.write('**') self.visit(node.kwargs) @@ -354,14 +336,8 @@ def write_comma(): def visit_Name(self, node): self.write(node.id) - def visit_Str(self, node): - self.write(repr(node.s)) - - def visit_Bytes(self, node): - self.write(repr(node.s)) - - def visit_Num(self, node): - self.write(repr(node.n)) + def visit_Constant(self, node): + self.write(repr(node.value)) def visit_Tuple(self, node): self.write('(') @@ -399,7 +375,7 @@ def visit_Dict(self, node): def visit_BinOp(self, node): self.write('(') self.visit(node.left) - self.write(' %s ' % BINOP_SYMBOLS[type(node.op)]) + self.write(f' {BINOP_SYMBOLS[type(node.op)]} ') self.visit(node.right) self.write(')') @@ -407,7 +383,7 @@ def visit_BoolOp(self, node): self.write('(') for idx, value in enumerate(node.values): if idx: - self.write(' %s ' % BOOLOP_SYMBOLS[type(node.op)]) + self.write(f' {BOOLOP_SYMBOLS[type(node.op)]} ') self.visit(value) self.write(')') @@ -415,7 +391,7 @@ def visit_Compare(self, node): self.write('(') self.visit(node.left) for op, right in zip(node.ops, node.comparators): - self.write(' %s ' % CMPOP_SYMBOLS[type(op)]) + self.write(f' {CMPOP_SYMBOLS[type(op)]} ') self.visit(right) self.write(')') @@ -549,4 +525,5 @@ def visit_Assert(self, node): if __name__ == '__main__': import sys - print to_source(parse(open(sys.argv[1]).read())) + from attest.ast import parse + print(to_source(parse(open(sys.argv[1]).read()))) diff --git a/attest/collectors.py b/attest/collectors.py index c2b7cad..7a47cf9 100644 --- a/attest/collectors.py +++ b/attest/collectors.py @@ -1,6 +1,3 @@ -# coding:utf-8 -from __future__ import with_statement - import inspect import re import sys @@ -23,7 +20,7 @@ ] -class Tests(object): +class Tests: """Collection of test functions. :param tests: @@ -54,7 +51,7 @@ class Tests(object): def __init__(self, tests=(), contexts=None, replace_tests=False, replace_contexts=False): self._tests = [] - if isinstance(tests, basestring): + if isinstance(tests, str): self.register(tests) else: for collection in tests: @@ -87,7 +84,8 @@ def test(self, func): def wrapper(): with nested(self._contexts) as context: context = [c for c in context if c is not None] - argc = len(inspect.getargspec(func)[0]) + sig = inspect.signature(func) + argc = len(sig.parameters) args = [] for arg in context: if type(arg) is tuple: # type() is intentional @@ -205,7 +203,7 @@ def register(self, tests): if inspect.isclass(tests): self._tests.extend(tests()) return tests - elif isinstance(tests, basestring): + elif isinstance(tests, str): def istests(obj): return isinstance(obj, Tests) obj = import_dotted_name(tests) @@ -264,10 +262,10 @@ def test_case(self): if not name: name = "unnamed" if not name.startswith("test_"): - name = "test_%s" % (name, ) + name = f"test_{name}" count = counts.increment(name) if count > 1: - name = "%s_%s" % (name, count) + name = f"{name}_{count}" methods[name] = staticmethod(func) return type("Tests", (TestCase, ), methods) @@ -314,7 +312,7 @@ def run(self, reporter=auto_reporter, raise else: break - except BaseException, e: + except BaseException as e: result.time = time() - result.time result.error = e result.stdout, result.stderr = out, err @@ -388,7 +386,7 @@ def wrapper(self): return wrapper -class TestBase(object): +class TestBase: """Base for test classes. Decorate test methods with :func:`test`. Needs to be registered with a :class:`Tests` collection to be run. For setup and teardown, override :meth:`__context__` like a diff --git a/attest/contexts.py b/attest/contexts.py index 86f5686..2d2b061 100644 --- a/attest/contexts.py +++ b/attest/contexts.py @@ -1,14 +1,10 @@ import sys from contextlib import contextmanager +from io import StringIO from shutil import rmtree from tempfile import mkdtemp -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - from attest import statistics from attest.deprecated import _repr @@ -33,7 +29,7 @@ def capture_output(): from attest import capture_output >>> with capture_output() as (out, err): - ... print 'Captured' + ... print('Captured') ... >>> out ['Captured'] @@ -68,20 +64,20 @@ def disable_imports(*names): .. versionadded:: 0.4 """ - import __builtin__ - import_ = __builtin__.__import__ + import builtins + import_ = builtins.__import__ def __import__(name, *args, **kwargs): if name in names: - raise ImportError('%r is disabled' % name) + raise ImportError(f'{name!r} is disabled') return import_(name, *args, **kwargs) - __builtin__.__import__ = __import__ + builtins.__import__ = __import__ try: yield finally: - __builtin__.__import__ = import_ + builtins.__import__ = import_ -class Error(object): +class Error: """Container of metadata for an exception caught by :func:`raises`. Attribute access and string adaption is forwarded to the exception @@ -102,7 +98,7 @@ def __str__(self): return str(self.exc) def __repr__(self): - return u'' % repr(self.exc) + return f'' @contextmanager @@ -137,11 +133,11 @@ def raises(*exceptions): error = Error() try: yield error - except exceptions, e: + except exceptions as e: error.exc = e else: exceptions = exceptions[0] if len(exceptions) == 1 else exceptions - raise AssertionError("didn't raise %s when expected" % _repr(exceptions)) + raise AssertionError(f"didn't raise {_repr(exceptions)} when expected") @contextmanager @@ -176,7 +172,7 @@ def warns(*warnings, **opts): >>> with warns(UserWarning) as captured: ... warnings.warn("Example warning", UserWarning) ... - >>> unicode(captured[0]) == "Example warning" + >>> str(captured[0]) == "Example warning" True :param any: Require only *one* of the warnings to be issued (rather than diff --git a/attest/deprecated.py b/attest/deprecated.py index 0e1ec0e..5a4e1a4 100644 --- a/attest/deprecated.py +++ b/attest/deprecated.py @@ -11,7 +11,7 @@ ] -class Loader(object): +class Loader: """Run tests with Attest via distribute. .. deprecated:: 0.5 @@ -41,7 +41,7 @@ def assert_(expr, msg=None): return expr -class Assert(object): +class Assert: """Wrap an object such that boolean operations on it fails with an :exc:`AssertionError` if the operation results in :const:`False`, with more helpful error messages on failure than `assert`. @@ -99,7 +99,7 @@ def __init__(self, *args): name = predicate.__name__ arglist = ', '.join(map(_repr, args)) self.obj = assert_(predicate(*args), - 'not %s(%s)' % (name, arglist)) + f'not {name}({arglist})') @property def __class__(self): @@ -146,11 +146,11 @@ def __getitem__(self, key): def __eq__(self, obj): """Test for equality with ``==``.""" - return assert_(self.obj == obj, '%r != %r' % (self.obj, obj)) + return assert_(self.obj == obj, f'{self.obj!r} != {obj!r}') def __ne__(self, obj): """Test for inequality with ``!=``.""" - return assert_(self.obj != obj, '%r == %r' % (self.obj, obj)) + return assert_(self.obj != obj, f'{self.obj!r} == {obj!r}') def is_(self, obj): """The `is` operator is not overridable, for good reasons @@ -165,7 +165,7 @@ def is_(self, obj): """ if isinstance(obj, Assert): obj = obj.obj - return assert_(self.obj is obj, '%r is not %r' % (self.obj, obj)) + return assert_(self.obj is obj, f'{self.obj!r} is not {obj!r}') def is_not(self, obj): """The negated form of :meth:`is_`, corresponding to the ``is not`` @@ -179,11 +179,11 @@ def is_not(self, obj): """ if isinstance(obj, Assert): obj = obj.obj - return assert_(self.obj is not obj, '%r is %r' % (self.obj, obj)) + return assert_(self.obj is not obj, f'{self.obj!r} is {obj!r}') def __contains__(self, obj): """Test for membership with `in`.""" - return assert_(obj in self.obj, '%r not in %r' % (obj, self.obj)) + return assert_(obj in self.obj, f'{obj!r} not in {self.obj!r}') def in_(self, obj): """Assert membership. While you can use the `in` operator, @@ -196,7 +196,7 @@ def in_(self, obj): Assert(2).in_([1, 2, 3]) """ - return assert_(self.obj in obj, '%r not in %r' % (self.obj, obj)) + return assert_(self.obj in obj, f'{self.obj!r} not in {obj!r}') def not_in(self, obj): """The negated form of :meth:`in_`, corresponding to the ``not in`` @@ -207,27 +207,27 @@ def not_in(self, obj): Assert(0).not_in([1, 2, 3]) """ - return assert_(self.obj not in obj, '%r in %r' % (self.obj, obj)) + return assert_(self.obj not in obj, f'{self.obj!r} in {obj!r}') def __lt__(self, obj): """Test for lesserness with ``<``.""" - return assert_(self.obj < obj, '%r >= %r' % (self.obj, obj)) + return assert_(self.obj < obj, f'{self.obj!r} >= {obj!r}') def __le__(self, obj): """Test for lesserness or equality with ``<=``.""" - return assert_(self.obj <= obj, '%r > %r' % (self.obj, obj)) + return assert_(self.obj <= obj, f'{self.obj!r} > {obj!r}') def __gt__(self, obj): """Test for greaterness with ``>``.""" - return assert_(self.obj > obj, '%r <= %r' % (self.obj, obj)) + return assert_(self.obj > obj, f'{self.obj!r} <= {obj!r}') def __ge__(self, obj): """Test for greaterness or equality with ``>=``.""" - return assert_(self.obj >= obj, '%r < %r' % (self.obj, obj)) + return assert_(self.obj >= obj, f'{self.obj!r} < {obj!r}') - def __nonzero__(self): + def __bool__(self): """Test for truthiness in boolean context.""" - return bool(assert_(self.obj, 'not %r' % self.obj)) + return bool(assert_(self.obj, f'not {self.obj!r}')) @staticmethod @contextmanager @@ -250,14 +250,14 @@ def raises(*exceptions): proxy = Assert() try: yield proxy - except exceptions, error: + except exceptions as error: proxy.obj = error else: if len(exceptions) > 1: errors = '(' + ', '.join(e.__name__ for e in exceptions) + ')' else: errors = exceptions[0].__name__ - raise AssertionError("didn't raise %s" % errors) + raise AssertionError(f"didn't raise {errors}") @staticmethod @contextmanager @@ -278,7 +278,7 @@ def not_raising(exception): try: yield except exception: - raise AssertionError('raised %s' % exception.__name__) + raise AssertionError(f'raised {exception.__name__}') @staticmethod def isinstance(obj, classinfo): @@ -291,7 +291,7 @@ def isinstance(obj, classinfo): if isinstance(obj, Assert): obj = obj.obj return assert_(isinstance(obj, classinfo), - 'not isinstance(%r, %s)' % (obj, _repr(classinfo))) + f'not isinstance({obj!r}, {_repr(classinfo)})') @staticmethod def not_isinstance(obj, classinfo): @@ -303,7 +303,7 @@ def not_isinstance(obj, classinfo): if isinstance(obj, Assert): obj = obj.obj return assert_(not isinstance(obj, classinfo), - 'isinstance(%r, %s)' % (obj, _repr(classinfo))) + f'isinstance({obj!r}, {_repr(classinfo)})') @staticmethod def issubclass(obj, cls): @@ -316,7 +316,7 @@ def issubclass(obj, cls): if isinstance(obj, Assert): obj = obj.obj return assert_(issubclass(obj, cls), - 'not issubclass(%s, %s)' % (_repr(obj), _repr(cls))) + f'not issubclass({_repr(obj)}, {_repr(cls)})') @staticmethod def not_issubclass(obj, cls): @@ -328,7 +328,7 @@ def not_issubclass(obj, cls): if isinstance(obj, Assert): obj = obj.obj return assert_(not issubclass(obj, cls), - 'issubclass(%s, %s)' % (_repr(obj), _repr(cls))) + f'issubclass({_repr(obj)}, {_repr(cls)})') @property def json(self): @@ -410,7 +410,7 @@ def __repr__(self): Assert(repr(obj)) == 'expectation' """ - return 'Assert(%r)' % self.obj + return f'Assert({self.obj!r})' def _repr(obj): @@ -421,5 +421,5 @@ def _repr(obj): if inspect.isclass(obj): return obj.__name__ elif type(obj) is tuple: - return '(%s)' % ', '.join(map(_repr, obj)) + return f'({", ".join(map(_repr, obj))})' return repr(obj) diff --git a/attest/hook.py b/attest/hook.py index 0aff939..87c6b14 100644 --- a/attest/hook.py +++ b/attest/hook.py @@ -1,13 +1,12 @@ -from __future__ import with_statement - -import imp +import importlib.machinery +import importlib.util import inspect -import os import sys +import types +from pathlib import Path -from attest import ast, statistics -from attest.codegen import to_source, SourceGenerator - +from attest import ast, statistics +from attest.codegen import SourceGenerator, to_source __all__ = ['COMPILES_AST', 'ExpressionEvaluator', @@ -68,7 +67,7 @@ def __repr__(self): def __str__(self): return '\n'.join((self.expr, repr(self))) - def __nonzero__(self): + def __bool__(self): return bool(eval(self.expr, self.globals, self.locals)) def eval(self, node): @@ -135,10 +134,10 @@ def assert_hook(expr, msg='', globals=None, locals=None): raise TestFailure(value, msg) -# Build AST nodes on 2.5 more easily +# Build AST nodes more easily def _build(node, **kwargs): node = node() - for key, value in kwargs.iteritems(): + for key, value in kwargs.items(): setattr(node, key, value) return node @@ -188,12 +187,12 @@ def make_module(self, name, newpath=None): :returns: The module object. """ - module = imp.new_module(name) + module = types.ModuleType(name) module.__file__ = self.filename if newpath: module.__path__ = newpath sys.modules[name] = module - exec self.code in vars(module) + exec(self.code, vars(module)) return module @property @@ -212,28 +211,32 @@ def code(self): return compile(to_source(self.node), self.filename, 'exec') def visit_Assert(self, node): - args = [_build(ast.Str, s=to_source(node.test)), - node.msg if node.msg is not None else _build(ast.Str, s=''), + if node.msg is not None: + msg = node.msg + else: + msg = _build(ast.Constant, value='') + args = [_build(ast.Constant, value=to_source(node.test)), + msg, _build(ast.Call, func=_build(ast.Name, id='globals', ctx=ast.Load()), - args=[], keywords=[], starargs=None, kwargs=None), + args=[], keywords=[]), _build(ast.Call, func=_build(ast.Name, id='locals', ctx=ast.Load()), - args=[], keywords=[], starargs=None, kwargs=None) + args=[], keywords=[]) ] return ast.copy_location( _build(ast.Expr, value=_build(ast.Call, func=_build(ast.Name, id='assert_hook', ctx=ast.Load()), - args=args, keywords=[], starargs=None, kwargs=None)), node) + args=args, keywords=[])), node) -class AssertImportHookEnabledDescriptor(object): +class AssertImportHookEnabledDescriptor: def __get__(self, instance, owner): return any(isinstance(ih, owner) for ih in sys.meta_path) -class AssertImportHook(object): +class AssertImportHook: """An :term:`importer` that transforms imported modules with :class:`AssertTransformer`. @@ -266,53 +269,85 @@ def __exit__(self, exc_type, exc_value, traceback): sys.meta_path.remove(self) def find_module(self, name, path=None): - lastname = name.rsplit('.', 1)[-1] try: - self._cache[name] = imp.find_module(lastname, path), path - except ImportError: - return + # Use PathFinder directly to avoid triggering meta_path hooks again + spec = importlib.machinery.PathFinder.find_spec(name, path) + if spec is None: + return None + self._cache[name] = spec + except (ImportError, ModuleNotFoundError, ValueError): + return None return self def load_module(self, name): if name in sys.modules: return sys.modules[name] + spec = self._cache.get(name) + if spec is None: + raise ImportError(f'cannot find module {name}') + source, filename, newpath = self.get_source(name) - (fd, fn, info), path = self._cache[name] if source is None: - return imp.load_module(name, fd, fn, info) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module transformer = AssertTransformer(source, filename) if not transformer.should_rewrite: - fd, fn, info = imp.find_module(name.rsplit('.', 1)[-1], path) - return imp.load_module(name, fd, fn, info) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module try: return transformer.make_module(name, newpath) - except Exception, err: - raise ImportError('cannot import %s: %s' % (name, err)) + except Exception as err: + raise ImportError(f'cannot import {name}: {err}') from err def get_source(self, name): - try: - (fd, fn, info), path = self._cache[name] - except KeyError: + spec = self._cache.get(name) + if spec is None: raise ImportError(name) code = filename = newpath = None - if info[2] == imp.PY_SOURCE: - filename = fn - with fd: - code = fd.read() - elif info[2] == imp.PY_COMPILED: - filename = fn[:-1] - with open(filename, 'U') as f: - code = f.read() - elif info[2] == imp.PKG_DIRECTORY: - filename = os.path.join(fn, '__init__.py') - newpath = [fn] - with open(filename, 'U') as f: - code = f.read() + + if spec.submodule_search_locations is not None: + # It's a package + filename_path = None + if spec.origin: + filename_path = Path(spec.origin) + else: + first_location = next(iter(spec.submodule_search_locations), None) + if first_location is not None: + filename_path = Path(first_location) / '__init__.py' + newpath = spec.submodule_search_locations + if filename_path is not None: + filename = str(filename_path) + try: + code = filename_path.read_text() + except (OSError, TypeError, ValueError): # Missing or invalid file paths + pass + elif isinstance(spec.loader, importlib.machinery.SourceFileLoader): + # It's a regular Python source file + if spec.origin: + filename_path = Path(spec.origin) + filename = str(filename_path) + try: + code = filename_path.read_text() + except OSError: # Missing or unreadable files + pass + elif isinstance(spec.loader, importlib.machinery.SourcelessFileLoader): + # It's a compiled Python file (.pyc) + if spec.origin and spec.origin.endswith('.pyc'): + filename_path = Path(spec.origin).with_suffix('.py') + filename = str(filename_path) + try: + code = filename_path.read_text() + except OSError: # Missing source file + pass return code, filename, newpath diff --git a/attest/pygments.py b/attest/pygments.py index d422c3a..eedbfaa 100644 --- a/attest/pygments.py +++ b/attest/pygments.py @@ -1,4 +1,3 @@ -from __future__ import absolute_import from pygments.style import Style from pygments.token import * diff --git a/attest/reporters.py b/attest/reporters.py index c7cdb19..393a623 100644 --- a/attest/reporters.py +++ b/attest/reporters.py @@ -1,6 +1,3 @@ -# coding:utf-8 -from __future__ import absolute_import, with_statement - import inspect import os import sys @@ -8,9 +5,9 @@ import unittest import _ast -from os import path -from pkg_resources import iter_entry_points -from datetime import datetime +from os import path +from importlib.metadata import entry_points +from datetime import datetime try: from abc import ABCMeta, abstractmethod except ImportError: @@ -48,7 +45,7 @@ ] -class TestResult(object): +class TestResult: """Container for result data from running a test. .. versionadded:: 0.4 @@ -56,7 +53,7 @@ class TestResult(object): """ def __init__(self, **kwargs): - for key, value in kwargs.iteritems(): + for key, value in kwargs.items(): setattr(self, key, value) full_tracebacks = False @@ -129,15 +126,17 @@ def traceback(self): clean = self.raw_traceback lines = ['Traceback (most recent call last):\n'] lines += traceback.format_list(clean) - msg = str(self.error) - lines += traceback.format_exception_only(self.exc_info[0], msg) + lines += traceback.format_exception_only( + self.exc_info[0], + self.exc_info[1], + ) return ''.join(lines)[:-1] @property def assertion(self): if isinstance(self.error, TestFailure): expressions = str(self.error.value) - return '\n'.join('assert %s' % expr + return '\n'.join(f'assert {expr}' for expr in expressions.splitlines()) @property @@ -158,16 +157,16 @@ def equality_diff(self): if type(left) is type(right): asserter = case._type_equality_funcs.get(type(left)) if asserter is not None: - if isinstance(asserter, basestring): + if isinstance(asserter, str): asserter = getattr(case, asserter) try: asserter(left, right) - except AssertionError, exc: - return '%s\n' % exc.args[0] + except AssertionError as exc: + return f'{exc.args[0]}\n' def _test_loader_factory(reporter): - class Loader(object): + class Loader: def loadTestsFromNames(self, names, module=None): from .collectors import Tests Tests(names).run(reporter) @@ -175,7 +174,7 @@ def loadTestsFromNames(self, names, module=None): return Loader() -class AbstractReporter(object): +class AbstractReporter: """Optional base for reporters, serves as documentation and improves errors for incomplete reporters. @@ -272,26 +271,27 @@ def failure(self, result): self.failures.append(result) def finished(self): - print - print + print() + print() width, _ = utils.get_terminal_size() for result in self.failures: - print result.test_name + print(result.test_name) if result.test.__doc__: - print inspect.getdoc(result.test) - print '-' * width + print(inspect.getdoc(result.test)) + print('-' * width) if result.stdout: - print '->', '\n'.join(result.stdout) + print('->', '\n'.join(result.stdout)) if result.stderr: - print 'E:', '\n'.join(result.stderr) - print result.traceback - print + print('E:', '\n'.join(result.stderr)) + print(result.traceback) + print() result.debug() - print 'Failures: %s/%s (%s assertions)' % (len(self.failures), - self.total, - statistics.assertions) + print( + f'Failures: {len(self.failures)}/{self.total} ' + f'({statistics.assertions} assertions)' + ) if self.failures: raise SystemExit(1) @@ -369,7 +369,7 @@ def finished(self): formatter = TerminalFormatter(bg=self.style) if self.colorscheme is not None: from pygments.token import string_to_tokentype - for token, value in self.colorscheme.iteritems(): + for token, value in self.colorscheme.items(): token = string_to_tokentype(token.capitalize()) formatter.colorscheme[token] = (value, value) else: @@ -386,26 +386,26 @@ def highlight(text, _lexer, _formatter): if self.counter: self.progress.finish() - print + print() width, _ = utils.get_terminal_size() def show(result): - print colorize('bold', result.test_name) + print(colorize('bold', result.test_name)) if result.test.__doc__: - print inspect.getdoc(result.test) - print colorize('faint', '─' * width) + print(inspect.getdoc(result.test)) + print(colorize('faint', '─' * width)) for line in result.stdout: - print colorize('bold', '→'), - print line + print(colorize('bold', '→'), end=' ') + print(line) for line in result.stderr: - print colorize('red', '→'), - print line + print(colorize('red', '→'), end=' ') + print(line) if self.verbose: for result in self.passes: if result.stdout or result.stderr: show(result) - print + print() for result in self.failures: show(result) @@ -414,21 +414,21 @@ def show(result): # literal unicode strings) but I guess this depends on the source # file encoding. Tell Pygments to guess: try UTF-8 and then latin1. # Without an `encoding` argument, Pygments just uses latin1. - print highlight(result.traceback, + print(highlight(result.traceback, PythonTracebackLexer(encoding='guess'), - formatter) + formatter)) assertion = result.assertion if assertion is not None: - print highlight(assertion, + print(highlight(assertion, PythonLexer(encoding='guess'), - formatter) + formatter)) equality_diff = result.equality_diff if equality_diff is not None: - print highlight(equality_diff, + print(highlight(equality_diff, DiffLexer(encoding='guess'), - formatter) + formatter)) result.debug() @@ -436,8 +436,11 @@ def show(result): failed = colorize('red', str(len(self.failures))) else: failed = len(self.failures) - print 'Failures: %s/%s (%s assertions, %.3f seconds)' % ( - failed, self.counter, statistics.assertions, self.total_time) + print( + f'Failures: {failed}/{self.counter} ' + f'({statistics.assertions} assertions, ' + f'{self.total_time:.3f} seconds)' + ) if self.failures: raise SystemExit(1) @@ -477,30 +480,32 @@ class XmlReporter(AbstractReporter): """ def __init__(self): - self.escape = __import__('cgi').escape + self.escape = __import__('html').escape def begin(self, tests): - print '' - print '' % len(tests) + print('') + print(f'') def success(self, result): - print ' ' % result.test_name + print(f' ') def failure(self, result): if isinstance(result.error, AssertionError): tag = 'fail' else: tag = 'error' - print ' <%s name="%s" type="%s">' % (tag, result.test_name, - result.exc_info[0].__name__) - print self.escape('\n'.join(' ' * 4 + line + print( + f' <{tag} name="{result.test_name}" ' + f'type="{result.exc_info[0].__name__}">' + ) + print(self.escape('\n'.join(' ' * 4 + line for line in result.traceback.splitlines()), - quote=True) - print ' ' % tag + quote=True)) + print(f' ') def finished(self): - print '' + print('') class XUnitReporter(AbstractReporter): @@ -509,7 +514,7 @@ class XUnitReporter(AbstractReporter): def __init__(self, file=None): self.file = file - self.escape = __import__('cgi').escape + self.escape = __import__('html').escape self.reports = [] self.errors = 0 self.failures = 0 @@ -518,7 +523,7 @@ def __init__(self, file=None): try: import socket self.hostname = socket.gethostname() - except: + except: # noqa: E722 - Intentionally catch all exceptions for fallback self.hostname = 'unknown' self.timestamp = datetime.isoformat(datetime.today()) @@ -529,10 +534,12 @@ def success(self, result): self.successes += 1 self.total_time += result.time self.reports.append( - '' % ( - result.test_name, result.test.__name__, result.time)) + f'' + ) if self.file: - print result.test_name, "... ok" + print(result.test_name, "... ok") def failure(self, result): self.total_time += result.time @@ -543,40 +550,44 @@ def failure(self, result): tag = 'error' self.errors += 1 - error = '\n' % ( - result.test_name, result.test.__name__, result.time) + error = ( + f'\n' + ) - error += '<%s type="%s" message="%s">' + f'\n\n' % tag + error += f'\n]]>\n\n' self.reports.append(error) if self.file: - print result.test_name, "... ", tag + print(result.test_name, "... ", tag) def finished(self): out = '\n' - out += ('\n') % ( - (self.errors + self.failures + self.successes), - self.errors, - self.failures, - self.hostname, - self.timestamp, - self.total_time) + out += ( + f'\n' + ) out += '\n' out += '\n'.join(self.reports) out += '\n\n' if not self.file: - print out + print(out) else: with open(self.file, "w") as f: f.write(out) @@ -619,7 +630,7 @@ def failure(self, result): type, msg = result.exc_info[0].__name__, str(result.exc_info[1]) if msg: msg = ': ' + msg - print "%s:%s: %s%s" % (fn, lineno, type, msg) + print(f"{fn}:{lineno}: {type}{msg}") def finished(self): if self.failed: @@ -662,14 +673,15 @@ def get_reporter_by_name(name, default='auto'): Reporters are registered via setuptools entry points. """ + eps = entry_points(group='attest.reporters') reporter = None if name is not None: - reporter = list(iter_entry_points('attest.reporters', name)) + reporter = list(eps.select(name=name)) if not reporter: - reporter = list(iter_entry_points('attest.reporters', default)) + reporter = list(eps.select(name=default)) if not reporter: raise KeyError - return reporter[0].load(require=False) + return reporter[0].load() def get_all_reporters(): @@ -685,5 +697,5 @@ def get_all_reporters(): .. versionadded:: 0.4 """ - for ep in iter_entry_points('attest.reporters'): + for ep in entry_points(group='attest.reporters'): yield ep.name diff --git a/attest/run.py b/attest/run.py index 6211101..5c0f817 100644 --- a/attest/run.py +++ b/attest/run.py @@ -1,81 +1,101 @@ -from __future__ import with_statement - -import sys +import argparse import os +import sys +from importlib.metadata import version from os import path -from pkg_resources import get_distribution -from optparse import OptionParser, make_option from attest.collectors import Tests +from attest.hook import AssertImportHook from attest.reporters import get_all_reporters, get_reporter_by_name from attest.utils import parse_options -from attest.hook import AssertImportHook def make_parser(**kwargs): - args = dict( - prog='attest', - usage='%prog [options] [tests...] [key=value...]', - version=get_distribution('Attest').version, - - description=( + parser_kwargs = { + 'prog': 'attest', + 'usage': '%(prog)s [options] [tests...] [key=value...]', + 'description': ( 'The positional "tests" are dotted ' 'names for modules or packages that are scanned ' 'recursively for Tests instances, or dotted names ' 'for any other object that iterates over tests. If ' 'not provided, packages in the working directory ' - 'are scanned.\n' + 'are scanned. ' 'The key/value pairs are passed to the ' 'reporter constructor, after some command-line ' 'friendly parsing.' ), + } + parser_kwargs.update(kwargs) + parser = argparse.ArgumentParser(**parser_kwargs) + + # Add version argument + parser.add_argument( + '--version', + action='version', + version=f"%(prog)s {version('attest')}" + ) + + # Add optional arguments + parser.add_argument( + '-d', '--debugger', + action='store_true', + help='enter pdb for failing tests', + ) + parser.add_argument( + '-r', '--reporter', + metavar='NAME', + help='select reporter by name' + ) + parser.add_argument( + '-l', '--list-reporters', + action='store_true', + help='list available reporters' + ) + parser.add_argument( + '-n', '--no-capture', + action='store_true', + help="don't capture stderr and stdout" + ) + parser.add_argument( + '--full-tracebacks', + action='store_true', + help="don't clean tracebacks" + ) + parser.add_argument( + '--fail-fast', + action='store_true', + help='stop at first failure' + ) + parser.add_argument( + '--native-assert', + action='store_true', + help="don't hook the assert statement" + ) + parser.add_argument( + '-p', '--profile', + metavar='FILENAME', + help='enable tests profiling and store results in filename' + ) + parser.add_argument( + '-k', '--keyboard-interrupt', + action='store_true', + help="Let KeyboardInterrupt exceptions (CTRL+C) propagate" + ) - option_list=[ - make_option('-d', '--debugger', - action='store_true', - help='enter pdb for failing tests', - ), - make_option('-r', '--reporter', - metavar='NAME', - help='select reporter by name' - ), - make_option('-l', '--list-reporters', - action='store_true', - help='list available reporters' - ), - make_option('-n', '--no-capture', - action='store_true', - help="don't capture stderr and stdout" - ), - make_option('--full-tracebacks', - action='store_true', - help="don't clean tracebacks" - ), - make_option('--fail-fast', - action='store_true', - help='stop at first failure' - ), - make_option('--native-assert', - action='store_true', - help="don't hook the assert statement" - ), - make_option('-p', '--profile', - metavar='FILENAME', - help='enable tests profiling and store results in filename' - ), - make_option('-k', '--keyboard-interrupt', - action='store_true', - help="Let KeyboardInterrupt exceptions (CTRL+C) propagate" - ), - ] + # Add positional arguments (tests and key=value pairs) + parser.add_argument( + 'args', + nargs='*', + help='test modules/packages and key=value options' ) - args.update(kwargs) - return OptionParser(**args) + + return parser def main(tests=None, **kwargs): parser = make_parser(**kwargs) - options, args = parser.parse_args() + args = parser.parse_args() # When run as a console script (i.e. ``attest``), the CWD isn't # ``sys.path[0]``, but it should be. It's important to do this early in @@ -85,38 +105,41 @@ def main(tests=None, **kwargs): if sys.path[0] not in ('', cwd): sys.path.insert(0, cwd) - if options.list_reporters: + if args.list_reporters: for reporter in get_all_reporters(): - print reporter + print(reporter) return - opts = parse_options(args) - reporter = get_reporter_by_name(options.reporter)(**opts) + opts = parse_options(args.args) + reporter = get_reporter_by_name(args.reporter)(**opts) if not tests: - names = [arg for arg in args if '=' not in arg] + names = [arg for arg in args.args if '=' not in arg] if not names: names = [name for name in os.listdir('.') - if path.isfile('%s/__init__.py' % name)] + if path.isfile(f'{name}/__init__.py')] - if options.native_assert: + if args.native_assert: tests = Tests(names) else: with AssertImportHook(): tests = Tests(names) def run(): - tests.run(reporter, full_tracebacks=options.full_tracebacks, - fail_fast=options.fail_fast, - debugger=options.debugger, - no_capture=options.no_capture, - keyboard_interrupt=options.keyboard_interrupt) - - if options.profile: - filename = options.profile + tests.run( + reporter, + full_tracebacks=args.full_tracebacks, + fail_fast=args.fail_fast, + debugger=args.debugger, + no_capture=args.no_capture, + keyboard_interrupt=args.keyboard_interrupt, + ) + + if args.profile: + filename = args.profile import cProfile cProfile.runctx('run()', globals(), locals(), filename) - print 'Wrote profiling results to %r.' % (filename,) + print(f'Wrote profiling results to {filename!r}.') else: run() diff --git a/attest/tests/_meta.py b/attest/tests/_meta.py index 9c98ee5..7b2c8be 100644 --- a/attest/tests/_meta.py +++ b/attest/tests/_meta.py @@ -15,7 +15,7 @@ def passing(): @suite.test def failing(): - print 'stdout' - print >>sys.stderr, 'stderr' + print('stdout') + print('stderr', file=sys.stderr) value = 1 + 1 assert value == 3 diff --git a/attest/tests/asserts.py b/attest/tests/asserts.py index 71ce270..b1a5c3d 100644 --- a/attest/tests/asserts.py +++ b/attest/tests/asserts.py @@ -1,5 +1,3 @@ -from __future__ import with_statement - from attest import Tests, Assert @@ -13,7 +11,7 @@ def raises(): try: with Assert.raises(RuntimeError): pass - except AssertionError, e: + except AssertionError as e: Assert(e).__str__() == "didn't raise RuntimeError" else: raise AssertionError("didn't fail for missing exception") @@ -22,7 +20,7 @@ def raises(): try: with Assert.raises(RuntimeError, ValueError): pass - except AssertionError, e: + except AssertionError as e: Assert(e).__str__() == "didn't raise (RuntimeError, ValueError)" else: raise AssertionError("didn't fail for missing exception") @@ -191,7 +189,7 @@ def isinstance(): Assert.isinstance('hello', int) error.__str__() == "not isinstance('hello', int)" - Assert.isinstance('hello', basestring) + Assert.isinstance('hello', str) @suite.test diff --git a/attest/tests/collectors.py b/attest/tests/collectors.py index 91b5d5f..066754f 100644 --- a/attest/tests/collectors.py +++ b/attest/tests/collectors.py @@ -1,4 +1,3 @@ -from __future__ import with_statement from attest import (AbstractReporter, Tests, TestBase, Assert, assert_hook, test, TestFailure) diff --git a/attest/tests/contexts.py b/attest/tests/contexts.py index 1e8bb43..7f07ca1 100644 --- a/attest/tests/contexts.py +++ b/attest/tests/contexts.py @@ -1,5 +1,3 @@ -from __future__ import with_statement - import sys import os from os import path @@ -19,8 +17,8 @@ def capture(): stdout, stderr = sys.stdout, sys.stderr with attest.capture_output() as (out, err): - print 'Capture the flag!' - print >>sys.stderr, 'Rapture the flag?' + print('Capture the flag!') + print('Rapture the flag?', file=sys.stderr) assert out == ['Capture the flag!'] assert err == ['Rapture the flag?'] @@ -51,7 +49,7 @@ def raises(): try: with attest.raises(RuntimeError): pass - except AssertionError, e: + except AssertionError as e: assert type(e) is AssertionError assert str(e) == "didn't raise RuntimeError when expected" else: @@ -61,9 +59,11 @@ def raises(): try: with attest.raises(RuntimeError, ValueError): pass - except AssertionError, e: + except AssertionError as e: assert type(e) is AssertionError - assert str(e) == "didn't raise (RuntimeError, ValueError) when expected" + assert str(e) == ( + "didn't raise (RuntimeError, ValueError) when expected" + ) else: raise AssertionError @@ -96,7 +96,7 @@ def warns(): warnings.warn("bar", DeprecationWarning) assert len(captured) == 1 - assert unicode(captured[0]) == "foo" + assert str(captured[0]) == "foo" with attest.raises(AssertionError): with attest.warns(UserWarning): @@ -109,10 +109,9 @@ def warns(): with attest.warns(UserWarning, DeprecationWarning, any=True): warnings.warn("foo") - if hasattr(warnings, "catch_warnings"): # not available in Python 2.5 - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - with attest.warns(UserWarning): - warnings.warn("foo") - with attest.raises(UserWarning): - warnings.warn("bar") + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + with attest.warns(UserWarning): + warnings.warn("foo") + with attest.raises(UserWarning): + warnings.warn("bar") diff --git a/attest/tests/hook.py b/attest/tests/hook.py index 4220ea4..e54e1cf 100644 --- a/attest/tests/hook.py +++ b/attest/tests/hook.py @@ -18,7 +18,7 @@ def eval(): '[v for v in [value]]': '[2]', } - for expr, result in samples.iteritems(): + for expr, result in samples.items(): expr = ExpressionEvaluator(expr, globals(), locals()) expr.late_visit() ev = repr(expr) diff --git a/attest/tests/import_hook.py b/attest/tests/import_hook.py new file mode 100644 index 0000000..1ff3e53 --- /dev/null +++ b/attest/tests/import_hook.py @@ -0,0 +1,421 @@ +"""Comprehensive tests for the AssertImportHook functionality.""" +import os +import sys +import tempfile +import shutil +from contextlib import contextmanager + +from attest import Tests, assert_hook +from attest.hook import AssertImportHook, AssertTransformer, TestFailure + + +suite = Tests() + + +@contextmanager +def temp_module(name, source, package=False): + """Create a temporary module for testing. + + :param name: Module name (can include dots for submodules) + :param source: Python source code + :param package: If True, create as a package with __init__.py + """ + tmpdir = tempfile.mkdtemp() + sys.path.insert(0, tmpdir) + + try: + parts = name.split('.') + if package: + # Create package structure + pkg_path = os.path.join(tmpdir, *parts) + os.makedirs(pkg_path, exist_ok=True) + filepath = os.path.join(pkg_path, '__init__.py') + else: + # Create regular module + if len(parts) > 1: + pkg_path = os.path.join(tmpdir, *parts[:-1]) + os.makedirs(pkg_path, exist_ok=True) + filepath = os.path.join(tmpdir, *parts[:-1], parts[-1] + '.py') + + with open(filepath, 'w') as f: + f.write(source) + + yield filepath + finally: + # Clean up + sys.path.remove(tmpdir) + # Remove from sys.modules + for key in list(sys.modules.keys()): + if key.startswith(name): + del sys.modules[key] + shutil.rmtree(tmpdir, ignore_errors=True) + + +@suite.test +def hook_enabled_disabled(): + """Test enable/disable functionality.""" + # Ensure we start with hook enabled + AssertImportHook.enable() + + # Use direct boolean check to avoid assert hook rewriting + if not AssertImportHook.enabled: + raise AssertionError("Hook should be enabled after enable()") + + # Disable and check + AssertImportHook.disable() + if AssertImportHook.enabled: + raise AssertionError("Hook should be disabled after disable()") + + # Enable and check + AssertImportHook.enable() + if not AssertImportHook.enabled: + raise AssertionError("Hook should be enabled after second enable()") + + # Calling enable twice should not cause issues + AssertImportHook.enable() + if not AssertImportHook.enabled: + raise AssertionError("Hook should still be enabled after third enable()") + + +@suite.test +def hook_as_context_manager(): + """Test using AssertImportHook as a context manager.""" + # Disable first + AssertImportHook.disable() + assert AssertImportHook.enabled is False + + # Use as context manager + with AssertImportHook(): + assert AssertImportHook.enabled is True + + # Should be disabled again after exiting + assert AssertImportHook.enabled is False + + # Re-enable for other tests + AssertImportHook.enable() + + +@suite.test +def rewrite_simple_assertion(): + """Test that simple assertions are rewritten.""" + with temp_module('test_simple', ''' +from attest import assert_hook + +def test_func(): + x = 1 + assert x == 2 +'''): + with AssertImportHook(): + import test_simple + try: + test_simple.test_func() + assert False, "Should have raised TestFailure" + except TestFailure as e: + # Check that the assertion was rewritten and captured + assert e.value is not None + assert 'x == 2' in str(e.value) or '1 == 2' in str(e.value) + + +@suite.test +def rewrite_assertion_with_message(): + """Test that assertions with messages are rewritten.""" + with temp_module('test_message', ''' +from attest import assert_hook + +def test_func(): + assert False, "Custom error message" +'''): + with AssertImportHook(): + import test_message + try: + test_message.test_func() + assert False, "Should have raised TestFailure" + except TestFailure as e: + assert str(e) == "Custom error message" + + +@suite.test +def no_rewrite_without_assert_hook_import(): + """Test that modules without assert_hook import are not rewritten.""" + with temp_module('test_no_hook', ''' +def test_func(): + assert False, "Standard assertion" +'''): + with AssertImportHook(): + import test_no_hook + try: + test_no_hook.test_func() + assert False, "Should have raised AssertionError" + except AssertionError as e: + # Should be a regular AssertionError, not TestFailure + assert type(e).__name__ == 'AssertionError' + assert str(e) == "Standard assertion" + + +@suite.test +def package_with_assert_hook(): + """Test that packages with __init__.py are handled correctly.""" + with temp_module('test_pkg', ''' +from attest import assert_hook + +value = 42 + +assert value == 42 # This should not fail +''', package=True): + with AssertImportHook(): + import test_pkg + assert test_pkg.value == 42 + + +@suite.test +def package_with_submodule(): + """Test that packages with submodules work correctly.""" + tmpdir = tempfile.mkdtemp() + sys.path.insert(0, tmpdir) + + try: + # Create package structure + pkg_path = os.path.join(tmpdir, 'test_pkg2') + os.makedirs(pkg_path) + + # Create __init__.py with assert_hook + with open(os.path.join(pkg_path, '__init__.py'), 'w') as f: + f.write('from attest import assert_hook\n') + + # Create submodule + with open(os.path.join(pkg_path, 'submod.py'), 'w') as f: + f.write('from attest import assert_hook\nvalue = 123\n') + + with AssertImportHook(): + import test_pkg2 + from test_pkg2 import submod + assert submod.value == 123 + finally: + sys.path.remove(tmpdir) + for key in list(sys.modules.keys()): + if key.startswith('test_pkg2'): + del sys.modules[key] + shutil.rmtree(tmpdir, ignore_errors=True) + + +@suite.test +def module_already_imported(): + """Test that already imported modules are returned from cache.""" + with temp_module('test_cached', ''' +from attest import assert_hook +value = 999 +'''): + with AssertImportHook(): + import test_cached + first_import = test_cached + + # Import again + import test_cached + second_import = test_cached + + # Should be the same module object + assert first_import is second_import + assert test_cached.value == 999 + + +@suite.test +def assert_transformer_should_rewrite(): + """Test AssertTransformer.should_rewrite property.""" + # Source with assert_hook import + source_with_hook = ''' +from attest import assert_hook + +def test(): + assert True +''' + transformer = AssertTransformer(source_with_hook, '') + assert transformer.should_rewrite is True + + # Source without assert_hook import + source_without_hook = ''' +def test(): + assert True +''' + transformer = AssertTransformer(source_without_hook, '') + assert transformer.should_rewrite is False + + # Source with different import + source_other_import = ''' +from attest import Tests + +def test(): + assert True +''' + transformer = AssertTransformer(source_other_import, '') + assert transformer.should_rewrite is False + + +@suite.test +def assert_transformer_make_module(): + """Test AssertTransformer.make_module creates valid modules.""" + source = ''' +from attest import assert_hook +x = 100 +''' + transformer = AssertTransformer(source, '') + module = transformer.make_module('test_make_module') + + # Check module is in sys.modules + assert 'test_make_module' in sys.modules + assert sys.modules['test_make_module'] is module + + # Check module attributes + assert hasattr(module, 'x') + assert module.x == 100 + + # Clean up + del sys.modules['test_make_module'] + + +@suite.test +def complex_assertion_expressions(): + """Test that complex assertion expressions are rewritten correctly.""" + with temp_module('test_complex', ''' +from attest import assert_hook + +def test_func(): + data = {'key': [1, 2, 3]} + assert data['key'][1] == 5 +'''): + with AssertImportHook(): + import test_complex + try: + test_complex.test_func() + assert False, "Should have raised TestFailure" + except TestFailure as e: + # The error should contain useful debugging info + assert e.value is not None + + +@suite.test +def multiple_assertions_in_function(): + """Test that multiple assertions in one function work correctly.""" + with temp_module('test_multiple', ''' +from attest import assert_hook + +def test_func(): + assert 1 == 1 # This should pass + assert 2 == 2 # This should pass + assert 3 == 4 # This should fail +'''): + with AssertImportHook(): + import test_multiple + try: + test_multiple.test_func() + assert False, "Should have raised TestFailure" + except TestFailure: + pass # Expected + + +@suite.test +def empty_module(): + """Test that empty modules can be imported.""" + with temp_module('test_empty', ''): + with AssertImportHook(): + import test_empty + # Should not raise any errors + + +@suite.test +def module_with_syntax_error(): + """Test that modules with syntax errors raise ImportError.""" + with temp_module('test_syntax_error', ''' +from attest import assert_hook + +def test_func( + # Missing closing parenthesis +'''): + with AssertImportHook(): + try: + import test_syntax_error + assert False, "Should have raised exception" + except (ImportError, SyntaxError): + pass # Expected + + +@suite.test +def assert_transformer_with_package(): + """Test AssertTransformer with package paths.""" + source = 'from attest import assert_hook\n' + transformer = AssertTransformer(source, '') + module = transformer.make_module('test_pkg_path', newpath=['/tmp']) + + # Check that __path__ is set for packages + assert hasattr(module, '__path__') + assert module.__path__ == ['/tmp'] + + # Clean up + del sys.modules['test_pkg_path'] + + +@suite.test +def hook_with_class_definitions(): + """Test that classes with assertions are rewritten correctly.""" + with temp_module('test_class', ''' +from attest import assert_hook + +class TestClass: + def method(self): + x = 10 + assert x == 10 + return x +'''): + with AssertImportHook(): + import test_class + obj = test_class.TestClass() + result = obj.method() + assert result == 10 + + +@suite.test +def hook_preserves_globals_and_locals(): + """Test that assert_hook preserves globals and locals correctly.""" + with temp_module('test_scope', ''' +from attest import assert_hook + +global_var = "global" + +def test_func(): + local_var = "local" + assert global_var == "global" + assert local_var == "local" +'''): + with AssertImportHook(): + import test_scope + test_scope.test_func() # Should not raise + + +@suite.test +def reimport_after_modification(): + """Test that re-importing after disable/enable works.""" + with temp_module('test_reimport', ''' +from attest import assert_hook +counter = 1 +'''): + # Use the hook directly, not as context manager + # since we want to manipulate enable/disable manually + AssertImportHook.enable() + try: + import test_reimport + assert test_reimport.counter == 1 + + # Disable hook + AssertImportHook.disable() + + # Remove from sys.modules + del sys.modules['test_reimport'] + + # Re-enable + AssertImportHook.enable() + + # Import again should work + import test_reimport + assert test_reimport.counter == 1 + finally: + # Ensure hook is enabled for other tests + AssertImportHook.enable() diff --git a/attest/tests/reporters.py b/attest/tests/reporters.py index fdaac29..c1f7189 100644 --- a/attest/tests/reporters.py +++ b/attest/tests/reporters.py @@ -1,5 +1,3 @@ -from __future__ import with_statement - import sys import inspect from traceback import format_exception_only @@ -13,7 +11,7 @@ SOURCEFILE = inspect.getsourcefile(_meta) LINENO = 21 -EXCEPTION = format_exception_only(TestFailure, '')[0].rstrip() +EXCEPTION = format_exception_only(TestFailure, TestFailure(''))[0].rstrip() suite = Tests() @@ -21,7 +19,7 @@ @suite.test def get_all_reporters(): - reporters = set(['auto', 'fancy', 'plain', 'xml', 'quickfix', 'xunit']) + reporters = {'auto', 'fancy', 'plain', 'xml', 'quickfix', 'xunit'} assert set(attest.get_all_reporters()) == reporters @@ -32,7 +30,7 @@ def get_reporter_by_name(): plain=attest.PlainReporter, xml=attest.XmlReporter, ) - for name, reporter in reporters.iteritems(): + for name, reporter in reporters.items(): assert attest.get_reporter_by_name(name) == reporter @@ -41,7 +39,7 @@ def auto_reporter(): # Inside tests, sys.stdout is not a tty assert isinstance(attest.auto_reporter(), attest.PlainReporter) - class FakeTTY(object): + class FakeTTY: def isatty(self): return True @@ -68,7 +66,7 @@ def xml_reporter(): ' ', ' ', ' Traceback (most recent call last):', - ' %s' % EXCEPTION, + f' {EXCEPTION}', ' ', '', ]): @@ -92,7 +90,7 @@ def plain_reporter(): '-> stdout', 'E: stderr', 'Traceback (most recent call last):', - '%s' % EXCEPTION, + f'{EXCEPTION}', '', 'Failures: 1/2 (1 assertions)', ]): @@ -107,7 +105,8 @@ def quickfix_reporter(): with Assert.raises(SystemExit): _meta.suite.run(attest.QuickFixReporter) - assert out == ['%s:%d: TestFailure' % (SOURCEFILE, LINENO)] + expected = f'{SOURCEFILE}:{LINENO}: TestFailure' + assert out == [expected] @suite.test diff --git a/attest/tests/utils.py b/attest/tests/utils.py index 53a6ecf..b4a080f 100644 --- a/attest/tests/utils.py +++ b/attest/tests/utils.py @@ -1,4 +1,3 @@ -from __future__ import with_statement import inspect from contextlib import contextmanager from attest import Tests, assert_hook, utils, disable_imports, raises @@ -48,8 +47,8 @@ def iter_mods(): '''ast codegen collectors contexts deprecated hook __main__ reporters run statistics utils pygments'''.split()] tests = ['attest.tests'] + ['attest.tests.' + mod for mod in - '''asserts classy collectors contexts hook _meta reporters utils - dummy dummy.foo'''.split()] + '''asserts classy collectors contexts hook import_hook _meta + reporters utils dummy dummy.foo'''.split()] found = list(utils.deep_iter_modules('attest')) expected = core + tests @@ -151,7 +150,7 @@ def two(): with ctx as args: assert signals == ['inner one', 'inner two'] assert args == ['one', 'two'] - 1/0 + 1/0 # noqa: B018 assert signals == ['inner one', 'inner two', 'outer two', 'outer one'] args = None @@ -168,7 +167,7 @@ def one(): @contextmanager def two(): signals.append('inner two') - 1/0 + 1/0 # noqa: B018 try: yield 'two' finally: @@ -198,6 +197,6 @@ def nested(): with utils.nested([]): assert 1 == 0, "message" - except AssertionError, e: - print e.args + except AssertionError as e: + print(e.args) assert e.args == ("message", ) diff --git a/attest/utils.py b/attest/utils.py index 24ffa3a..3139230 100644 --- a/attest/utils.py +++ b/attest/utils.py @@ -4,7 +4,6 @@ from contextlib import contextmanager from inspect import getmembers from pkgutil import iter_modules -from six import reraise __all__ = ['get_terminal_size', @@ -28,7 +27,7 @@ def get_terminal_size(default=(80, 24)): except ImportError: return default try: - ary = array('h', fcntl.fcntl(sys.stdin, termios.TIOCGWINSZ, chr(0) * 8)) + ary = array('h', fcntl.fcntl(sys.stdin, termios.TIOCGWINSZ, b'\0' * 8)) return ary[1], ary[0] except IOError: return default @@ -144,17 +143,17 @@ def nested(constructors): args.append(manager.__enter__()) exits.append(manager.__exit__) yield args - except: + except: # noqa: E722 - Context manager protocol requires catching all exceptions exc = sys.exc_info() finally: for exit in reversed(exits): try: if exit(*exc): exc = None, None, None - except: + except: # noqa: E722 - Must catch all exceptions from exit handlers for proper cleanup exc = sys.exc_info() if exc != (None, None, None): - reraise(*exc) + raise exc[1].with_traceback(exc[2]) class counter(dict): diff --git a/docs/conf.py b/docs/conf.py index a757697..bd3a8cf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,16 +1,14 @@ -# coding:utf-8 - import sys import os -from pkg_resources import get_distribution +from importlib.metadata import version as get_version sys.path.insert(0, os.path.abspath('..')) project = u'Attest' copyright = u'2010-2011, Dag Odenhall' -release = get_distribution(project).version +release = get_version('attest') version = release.split('dev', 1)[0] diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 10bc2db..5ee9fd4 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -27,12 +27,12 @@ Suppose we came up with an API like this: .. testsetup:: import json - import urllib2 + import urllib.request - class Package(object): + class Package: def __init__(self, name): - url = 'http://pypi.python.org/pypi/%s/json' % name + url = f'http://pypi.python.org/pypi/{name}/json' data = urllib2.urlopen(url).read() vars(self).update(json.loads(data)['info']) @@ -75,7 +75,7 @@ Save it as :file:`tests.py` and run it. What happens? As expected we get an :exc:`ImportError` because we haven't created our module yet. So that's the next step! First we just stub the class:: - class Package(object): + class Package: pass This should fail because this constructor doesn't take any arguments. Let's @@ -97,7 +97,7 @@ confirm this: Just as expected. OK - so we write a custom constructor:: - class Package(object): + class Package: def __init__(self, name): pass @@ -113,12 +113,12 @@ Still fails: It's time to write some real code! Here's our working module:: import json - import urllib2 + import urllib.request - class Package(object): + class Package: def __init__(self, name): - url = 'http://pypi.python.org/pypi/%s/json' % name + url = f'http://pypi.python.org/pypi/{name}/json' data = json.loads(urllib2.urlopen(url).read()) self.author = data['info']['author'] self.summary = data['info']['summary'] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..02809f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "attest" +version = "0.7" +description = "Modern, Pythonic unit testing." +readme = "README.rst" +license = "BSD-3-Clause" +authors = [ + { name = "Dag Odenhall", email = "dag.odenhall@gmail.com" } +] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Testing", +] +dependencies = [ + "progressbar2>=3.0", + "pygments", +] + +[project.urls] +Homepage = "https://github.com/dag/attest" +Repository = "https://github.com/dag/attest" + +[project.scripts] +attest = "attest.run:main" + +[project.entry-points."attest.reporters"] +xml = "attest:XmlReporter" +xunit = "attest:XUnitReporter" +quickfix = "attest:QuickFixReporter" +plain = "attest:PlainReporter" +fancy = "attest:FancyReporter" +auto = "attest:auto_reporter" + +[project.entry-points."pygments.styles"] +attest = "attest.pygments:Attest" + +[tool.hatch.build.targets.wheel] +packages = ["attest"] + +[tool.hatch.build.targets.sdist] +include = [ + "attest/", + "docs/", + "tests/", + "*.rst", + "*.py", + "LICENSE", + "MANIFEST.in", +] + +# Development dependencies (optional) +[project.optional-dependencies] +dev = [ + "ruff", + "sphinx", + "tox", +] +docs = [ + "sphinx", +] +test = [ + "tox", +] + +# Tox configuration +[tool.tox] +env_list = ["py311", "py312", "py313", "pypy3", "doctest"] + +[tool.tox.env_run_base] +commands = [["attest", "attest.tests"]] + +[tool.tox.env.doctest] +deps = ["sphinx"] +change_dir = "docs" +commands = [["sphinx-build", "-WEab", "{envname}", "-d", "{envtmpdir}/doctrees", ".", "{envtmpdir}/{envname}"]] + +[tool.tox.env.html] +deps = ["sphinx"] +change_dir = "docs" +commands = [["sphinx-build", "-WEab", "{envname}", "-d", "{envtmpdir}/doctrees", ".", "{envtmpdir}/{envname}"]] + +[tool.tox.env.linkcheck] +deps = ["sphinx"] +change_dir = "docs" +commands = [["sphinx-build", "-WEab", "{envname}", "-d", "{envtmpdir}/doctrees", ".", "{envtmpdir}/{envname}"]] + +[tool.tox.env.spelling] +deps = [ + "sphinx", + "sphinxcontrib-spelling>=1.0", + "pyenchant", +] +change_dir = "docs" +commands = [["sphinx-build", "-Eab", "{envname}", "-d", "{envtmpdir}/doctrees", ".", "{envtmpdir}/{envname}"]] + +# Ruff configuration +[tool.ruff] +target-version = "py311" +line-length = 79 + +[tool.ruff.format] +quote-style = "single" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "F403", "F405", "I001"] # Allow star imports and unused imports +"attest/ast.py" = ["F403", "F405"] # Star imports from _ast module are intentional +"attest/utils.py" = ["B020"] # loop control variable overrides intentional in module iteration +"attest/collectors.py" = ["B020"] # ditto diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b0af5b2..0000000 --- a/setup.cfg +++ /dev/null @@ -1,17 +0,0 @@ -[build_sphinx] -source-dir = docs -build-dir = build -all_files = 1 -builder = dirhtml - -[upload_docs] -upload-dir = build/dirhtml - -[egg_info] -tag_date = true -tag_build = dev - -[aliases] -build_docs = build_sphinx -b html -build_website = build_sphinx -Ea upload_docs -release = egg_info -RDb '' diff --git a/setup.py b/setup.py deleted file mode 100644 index 3c6352b..0000000 --- a/setup.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Attest -====== - -Attest is a unit testing framework built from the ground up with idiomatic -Python in mind. Unlike others, it is not built on top of unittest though it -provides compatibility by creating TestSuites from Attest collections. - -It has a functional API inspired by `Flask`_ and a class-based API that -mimics Python itself. The core avoids complicated assumptions leaving you -free to write tests however you prefer. - -.. _Flask: http://pypi.python.org/pypi/Flask/ - -:: - - from attest import Tests - math = Tests() - - @math.test - def arithmetics(): - assert 1 + 1 == 2 - - if __name__ == '__main__': - math.run() - -""" - -from setuptools import setup, find_packages - - -setup( - name='Attest', - version='0.6', - description='Modern, Pythonic unit testing.', - long_description=__doc__, - - author='Dag Odenhall', - author_email='dag.odenhall@gmail.com', - license='Simplified BSD', - url='https://github.com/dag/attest', - - packages=find_packages(), - - install_requires=[ - 'progressbar>=2.3', - 'Pygments', - 'six', - ], - - entry_points={ - 'attest.reporters': [ - 'xml = attest:XmlReporter', - 'xunit = attest:XUnitReporter', - 'quickfix = attest:QuickFixReporter', - 'plain = attest:PlainReporter', - 'fancy = attest:FancyReporter', - 'auto = attest:auto_reporter', - ], - 'pygments.styles': [ - 'attest = attest.pygments:Attest', - ], - 'console_scripts': [ - 'attest = attest.run:main', - ], - }, - - test_loader='attest:auto_reporter.test_loader', - test_suite='attest.tests', - use_2to3=True, - zip_safe=False, - - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.1', - 'Topic :: Software Development :: Testing', - ], -) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 902cf04..0000000 --- a/tox.ini +++ /dev/null @@ -1,31 +0,0 @@ -[tox] -envlist = py25, py26, py27, py31, py32, pypy, doctest - -[testenv] -commands = attest attest.tests [] - -[testenv:py25] -deps = simplejson - -[testenv:doctest] -deps = sphinx -changedir = docs -commands = sphinx-build -WEab {envname} -d {envtmpdir}/doctrees . {envtmpdir}/{envname} - -[testenv:html] -deps = sphinx -changedir = docs -commands = sphinx-build -WEab {envname} -d {envtmpdir}/doctrees . {envtmpdir}/{envname} - -[testenv:linkcheck] -deps = sphinx -changedir = docs -commands = sphinx-build -WEab {envname} -d {envtmpdir}/doctrees . {envtmpdir}/{envname} - -[testenv:spelling] -deps = - sphinx - sphinxcontrib-spelling>=1.0 - pyenchant -changedir = docs -commands = sphinx-build -Eab {envname} -d {envtmpdir}/doctrees . {envtmpdir}/{envname} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f4cde10 --- /dev/null +++ b/uv.lock @@ -0,0 +1,560 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "attest" +version = "0.7" +source = { editable = "." } +dependencies = [ + { name = "progressbar2" }, + { name = "pygments" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, + { name = "sphinx" }, + { name = "tox" }, +] +docs = [ + { name = "sphinx" }, +] +test = [ + { name = "tox" }, +] + +[package.metadata] +requires-dist = [ + { name = "progressbar2", specifier = ">=3.0" }, + { name = "pygments" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "sphinx", marker = "extra == 'dev'" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'dev'" }, + { name = "tox", marker = "extra == 'test'" }, +] +provides-extras = ["dev", "docs", "test"] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "progressbar2" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/24/3587e795fc590611434e4bcb9fbe0c3dddb5754ce1a20edfd86c587c0004/progressbar2-4.5.0.tar.gz", hash = "sha256:6662cb624886ed31eb94daf61e27583b5144ebc7383a17bae076f8f4f59088fb", size = 101449, upload-time = "2024-08-28T22:50:12.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/448f037fb0ffd0e8a63b625cf9f5b13494b88d15573a987be8aaa735579d/progressbar2-4.5.0-py3-none-any.whl", hash = "sha256:625c94a54e63915b3959355e6d4aacd63a00219e5f3e2b12181b76867bf6f628", size = 57132, upload-time = "2024-08-28T22:50:10.264Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, +] + +[[package]] +name = "python-utils" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/4c/ef8b7b1046d65c1f18ca31e5235c7d6627ca2b3f389ab1d44a74d22f5cc9/python_utils-3.9.1.tar.gz", hash = "sha256:eb574b4292415eb230f094cbf50ab5ef36e3579b8f09e9f2ba74af70891449a0", size = 35403, upload-time = "2024-11-26T00:38:58.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/69/31c82567719b34d8f6b41077732589104883771d182a9f4ff3e71430999a/python_utils-3.9.1-py2.py3-none-any.whl", hash = "sha256:0273d7363c7ad4b70999b2791d5ba6b55333d6f7a4e4c8b6b39fb82b5fab4613", size = 32078, upload-time = "2024-11-26T00:38:57.488Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, + { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tox" +version = "4.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/bf/0e4dbd42724cbae25959f0e34c95d0c730df03ab03f54d52accd9abfc614/tox-4.32.0.tar.gz", hash = "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff", size = 203330, upload-time = "2025-10-24T18:03:38.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/cc/e09c0d663a004945f82beecd4f147053567910479314e8d01ba71e5d5dea/tox-4.32.0-py3-none-any.whl", hash = "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551", size = 175905, upload-time = "2025-10-24T18:03:36.337Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +]