diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 5d62645..0000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -max-line-length = 104 -max-complexity = 18 -select = B,C,E,F,W,T4,B9 -ignore = E203, E226, E266, E501, W503 -per-file-ignores = __init__.py:F401 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d50fd81..bbafa9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,19 +13,18 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 with: fetch-depth: '0' - - name: Set up Python - uses: actions/setup-python@v2 + - uses: astral-sh/setup-uv@v8.2.0 with: - python-version: '3.8.x' + enable-cache: true + - name: Set up Python + run: uv python install - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build + run: uv sync --extra dev --frozen - name: Get next version - uses: anothrNick/github-tag-action@9885a4f9af674ba472de91fb3c0cb033ecb32b7e + uses: anothrNick/github-tag-action@1.75.0 id: bump_version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -33,35 +32,93 @@ jobs: WITH_V: false RELEASE_BRANCHES: main DRY_RUN: true + - name: Resolve release version + id: release_version + env: + TAG_VERSION: ${{ steps.bump_version.outputs.tag }} + NEW_TAG_VERSION: ${{ steps.bump_version.outputs.new_tag }} + run: | + current_version="$(uv run python - <<'PY' + import ast + + with open("src/leet2git/version.py", encoding="UTF8") as file: + tree = ast.parse(file.read()) + + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__version__": + print(ast.literal_eval(node.value)) + raise SystemExit + + raise SystemExit("Could not find __version__") + PY + )" + latest_pypi_version="$(uv run python - <<'PY' + import json + import urllib.request + + with urllib.request.urlopen("https://pypi.org/pypi/leet2git/json", timeout=20) as response: + print(json.load(response)["info"]["version"]) + PY + )" + + release_version="$current_version" + release_reason="none" + if [ "$NEW_TAG_VERSION" != "$TAG_VERSION" ]; then + release_version="$NEW_TAG_VERSION" + release_reason="tag" + elif [ "$current_version" != "$latest_pypi_version" ]; then + release_reason="package-version" + fi + + should_release=false + if [ "$release_reason" != "none" ]; then + should_release=true + fi + + echo "current_version=$current_version" >> "$GITHUB_OUTPUT" + echo "latest_pypi_version=$latest_pypi_version" >> "$GITHUB_OUTPUT" + echo "version=$release_version" >> "$GITHUB_OUTPUT" + echo "reason=$release_reason" >> "$GITHUB_OUTPUT" + echo "should_release=$should_release" >> "$GITHUB_OUTPUT" + + echo "Current version: $current_version" + echo "Latest PyPI version: $latest_pypi_version" + echo "Release version: $release_version" + echo "Release reason: $release_reason" - name: Update app version - if: ${{ steps.bump_version.outputs.new_tag != steps.bump_version.outputs.tag }} + if: ${{ steps.release_version.outputs.reason == 'tag' }} run: | - python src/leet2git/version.py ${{ steps.bump_version.outputs.new_tag }} + uv run python src/leet2git/version.py ${{ steps.release_version.outputs.version }} git config user.email "yurirocha15@gmail.com" git config user.name "Yuri Rocha" git add src/leet2git/version.py - git commit -m "bump version to ${{ steps.bump_version.outputs.new_tag }}" + git diff --cached --quiet || git commit -m "bump version to ${{ steps.release_version.outputs.version }}" - name: Push to protected branch - if: ${{ steps.bump_version.outputs.new_tag != steps.bump_version.outputs.tag }} - uses: CasperWA/push-protected@v2 + if: ${{ steps.release_version.outputs.reason == 'tag' }} + uses: CasperWA/push-protected@v2.16.0 with: token: ${{ secrets.PUSH_TO_PROTECTED_BRANCH }} branch: main unprotect_reviews: true - - name: Bump version and push tag - if: ${{ steps.bump_version.outputs.new_tag != steps.bump_version.outputs.tag }} - uses: anothrNick/github-tag-action@9885a4f9af674ba472de91fb3c0cb033ecb32b7e - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEFAULT_BUMP: none - WITH_V: true - RELEASE_BRANCHES: main + - name: Push release tag + if: ${{ steps.release_version.outputs.should_release == 'true' }} + run: | + tag="v${{ steps.release_version.outputs.version }}" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Tag $tag already exists" + else + git tag "$tag" + git push origin "$tag" + fi - name: Build package - if: ${{ steps.bump_version.outputs.new_tag != steps.bump_version.outputs.tag }} - run: python -m build + if: ${{ steps.release_version.outputs.should_release == 'true' }} + run: uv run python -m build - name: Publish package - if: ${{ steps.bump_version.outputs.new_tag != steps.bump_version.outputs.tag }} - uses: pypa/gh-action-pypi-publish@release/v1 + if: ${{ steps.release_version.outputs.should_release == 'true' }} + uses: pypa/gh-action-pypi-publish@v1.14.0 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 91a78dc..7c157ce 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,11 +39,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v4.36.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v4.36.2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v4.36.2 diff --git a/.github/workflows/format-lint.yml b/.github/workflows/format-lint.yml index bdd298b..833bf0c 100644 --- a/.github/workflows/format-lint.yml +++ b/.github/workflows/format-lint.yml @@ -6,18 +6,15 @@ jobs: format-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v8.2.0 with: - activate-environment: coding-practice - python-version: 3.8 - auto-activate-base: false + enable-cache: true + - name: Set up Python + run: uv python install - name: Setup - shell: bash -l {0} - run: make setup-dev - - name: Format with black and isort - shell: bash -l {0} + run: uv sync --extra dev --frozen + - name: Format with Ruff run: make format - - name: Lint with flake8, mypy and pylint - shell: bash -l {0} - run: make lint \ No newline at end of file + - name: Lint with Ruff and ty + run: make lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb9a691..a76401a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,15 +6,13 @@ jobs: unittest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v8.2.0 with: - activate-environment: coding-practice - python-version: 3.8 - auto-activate-base: false + enable-cache: true + - name: Set up Python + run: uv python install - name: Setup - shell: bash -l {0} - run: make setup-dev + run: uv sync --extra dev --frozen - name: Run unit tests - shell: bash -l {0} - run: make utest \ No newline at end of file + run: make utest diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index 79fc942..6c1dff6 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -8,19 +8,18 @@ jobs: validate-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 with: fetch-depth: '0' - - name: Set up Python - uses: actions/setup-python@v2 + - uses: astral-sh/setup-uv@v8.2.0 with: - python-version: '3.8.x' + enable-cache: true + - name: Set up Python + run: uv python install - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build + run: uv sync --extra dev --frozen - name: Get next version - uses: anothrNick/github-tag-action@9885a4f9af674ba472de91fb3c0cb033ecb32b7e + uses: anothrNick/github-tag-action@1.75.0 id: bump_version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -34,4 +33,4 @@ jobs: echo "Current tag: ${{ steps.bump_version.outputs.tag }}" echo "New tag: ${{ steps.bump_version.outputs.new_tag }}" - name: Build package - run: python -m build + run: uv run python -m build diff --git a/.mypy.ini b/.mypy.ini deleted file mode 100644 index 976ba02..0000000 --- a/.mypy.ini +++ /dev/null @@ -1,2 +0,0 @@ -[mypy] -ignore_missing_imports = True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea86eb7..5ced10d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,4 +5,4 @@ repos: name: format language: system entry: make format - types: [python] \ No newline at end of file + types: [python] diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 01a2a32..0000000 --- a/.pylintrc +++ /dev/null @@ -1,603 +0,0 @@ - -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Specify a score threshold to be exceeded before program exits with error. -fail-under=10.0 - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=0 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=print-statement, - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape, - # newly added from the default settings - bad-whitespace, # C0326 - bad-continuation, # C0330 - wrong-import-order, - too-many-instance-attributes, - ungrouped-imports, - redefined-outer-name, # added for convenience - unnecessary-pass, # need to allow pass - fixme, # need to allow TODO - no-self-use, # for not implemented method - R0913, - C0103, - R0801, - E1101 -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'error', 'warning', 'refactor', and 'convention' -# which contain the number of messages in each category, as well as 'statement' -# which is the total number of statements analyzed. This score is used by the -# global evaluation report (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it work, -# install the python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - -# Regular expression of note tags to take in consideration. -#notes-rgx= - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis). It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules=torch # pylint cannot find available methods in pytorch - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - -# List of decorators that change the signature of a decorated function. -signature-mutators= - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=104 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=yes - -# Minimum lines number of a similarity. -min-similarity-lines=5 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Bad variable names regexes, separated by a comma. If names match any regex, -# they will always be refused -bad-names-rgxs= - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Good variable names regexes, separated by a comma. If names match any regex, -# they will always be accepted -good-names-rgxs= - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no - -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no - - -[IMPORTS] - -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp, - __post_init__ - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=10 - -# Maximum number of attributes for a class (see R0902). -max-attributes=12 - -# Maximum number of boolean expressions in an if statement (see R0916). -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=22 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=60 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e8cbdf2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# Repository Guidelines + +## Project Overview + +`leet2git` is a Python CLI that synchronizes LeetCode problems and accepted submissions into a local Git repository. The package uses a `src/` layout and exposes the `leet2git` console command from `leet2git.leet2git:leet2git`. + +## Setup + +- Use Python 3.11 or newer. +- Install the package for local development with `uv sync --extra dev` or `make setup-dev`. +- Install only runtime dependencies with `uv sync` or `make setup`. +- The CLI reads browser cookies for LeetCode through `browser-cookie3`; do not add tests or scripts that require real user cookies unless the user explicitly asks. + +## Common Commands + +- `make format`: run Ruff format and Ruff autofix through `uv`. +- `make lint`: run Ruff and ty through `uv`. +- `make utest`: run tests under `tests` with coverage. +- `uv run pytest tests -s --verbose`: useful focused test command while developing tests. + +## Code Style + +- Follow the Ruff configuration in `pyproject.toml` and keep types friendly to `ty`. +- Keep compatibility with the supported Python versions declared in `pyproject.toml`. +- Prefer the existing Click command patterns in `src/leet2git/leet2git.py`. +- Keep user-facing CLI messages concise and actionable. +- Avoid broad refactors when fixing a narrow behavior; this codebase has several small modules with clear responsibilities. + +## Architecture Notes + +- `leet2git.py`: Click command entry points and command orchestration. +- `config_manager.py`: Pydantic config models plus platform-specific config/data paths via `platformdirs`. +- `leetcode_client.py`: async-first LeetCode HTTP/session interactions using `httpx`, with sync wrappers for the Click CLI. +- `question_db.py`: Pydantic question metadata models and local pickle-backed storage. +- `file_handler.py`, `default_handler.py`, `python_handler.py`: file generation and language-specific behavior. +- `readme_handler.py`: generated README updates for target solution repositories. +- `my_utils.py`: shared helpers used by commands. + +## Testing Guidance + +- Add tests under `tests/` for new behavior. The current test tree is minimal, so prefer focused unit tests around pure helpers or isolated file operations. +- Mock network calls, browser cookie access, editor launches, and LeetCode responses. +- Use temporary directories for generated repositories, config files, question databases, and README output. +- When changing CLI behavior, test with Click's testing utilities where practical. +- Preserve compatibility with existing pickle-backed question databases when changing `QuestionData`, `IdTitleMap`, or `QuestionDB.load`. +- Keep `ConfigManager.config` returning a plain dictionary unless the surrounding command/file-handler code is migrated together. +- When changing LeetCode request shapes, keep unit tests on `httpx.MockTransport` and run a live smoke check against public unauthenticated endpoints such as GraphQL `questionData` and `/api/problems/all/`. +- When changing authenticated LeetCode requests, run `uv run python scripts/smoke_leetcode_auth.py` if the user has explicitly allowed local browser-cookie checks. +- Treat broken LeetCode auth or request behavior as a LeetCode API/auth change to investigate; do not keep obsolete request paths as fallbacks. + +## Safety Notes + +- Do not commit real LeetCode cookies, generated user configs, local question databases, or downloaded solutions from a user's personal repository. +- Be careful with `leet2git reset --hard` and delete paths. Confirm behavior in tests with temporary directories before changing deletion logic. +- `ConfigManager.reset_config` opens an editor via `click.edit`; mock it in automated tests. diff --git a/MANIFEST.in b/MANIFEST.in index 8697257..a1b496b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ -include README.md MANIFEST.in LICENSE setup.py pyproject.toml +include README.md MANIFEST.in LICENSE pyproject.toml recursive-include src * -recursive-include requirements *.txt global-exclude *.pyc diff --git a/Makefile b/Makefile index 2758f4b..a91d496 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,22 @@ format: - isort . - black . + uv run ruff format . + uv run ruff check --fix . lint: - env PYTHONPATH=src pytest src --pylint --flake8 --mypy + uv run ruff check . + uv run ty check src setup: - pip install -e . + uv sync setup-dev: - make setup - pip install -r requirements/requirements.txt - pip install -r requirements/requirements-dev.txt + uv sync --extra dev utest: - env PYTHONPATH=src pytest tests -s --verbose --cov=src --cov-report=html --cov-report=term-missing --suppress-no-test-exit-code + uv run pytest tests -s --verbose --cov=src --cov-report=html --cov-report=term-missing --cov-fail-under=90 --suppress-no-test-exit-code + +build: + uv run python -m build tree: tree -I "$(shell cat .gitignore | tr -s '\n' '|')" diff --git a/README.md b/README.md index 0905516..0df3dd8 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # leet2git [![Build Status](https://github.com/yurirocha15/leetcode2github/workflows/Build/badge.svg)](https://github.com/yurirocha15/leetcode2github/workflows/Build/badge.svg) -[![Python 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Imports: isort](https://img.shields.io/badge/imports-isort-white)](https://pycqa.github.io/isort/) -[![Linting: flake8 & mypy & pylint](https://img.shields.io/badge/linting-flake8%20%26%20mypy%20%26%20pylint-green)](https://pypi.org/project/pytest-pylint/) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) +[![Code style: Ruff](https://img.shields.io/badge/code%20style-ruff-46a2f1.svg)](https://docs.astral.sh/ruff/) +[![Linting: Ruff & ty](https://img.shields.io/badge/linting-ruff%20%26%20ty-green)](https://docs.astral.sh/) [![Stable Version](https://img.shields.io/github/v/tag/yurirocha15/leetcode2github)](https://img.shields.io/github/v/tag/yurirocha15/leetcode2github) [![Latest Release](https://img.shields.io/github/v/release/yurirocha15/leetcode2github?color=%233D9970)](https://img.shields.io/github/v/release/yurirocha15/leetcode2github?color=%233D9970) [![Pypi Version](https://img.shields.io/pypi/v/leet2git)](https://pypi.org/project/leet2git/) @@ -34,7 +33,7 @@ $ pip install leet2git To install from source, download this repository, navigate to the folder and run: ```shell -$ pip install -e .[dev] +$ uv sync --extra dev ``` ## Usage @@ -270,4 +269,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! \ No newline at end of file +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/pyproject.toml b/pyproject.toml index 5e681bb..f5784a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,89 @@ -# --------- Black ------------- +[project] +name = "leet2git" +dynamic = ["version"] +description = "Tool to ease the integration between Leetcode and a Git repository" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [ + { name = "Yuri Rocha", email = "yurirocha15@gmail.com" }, +] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Utilities", +] +dependencies = [ + "autoimport>=1.6.1", + "beautifulsoup4>=4.14.3", + "browser-cookie3>=0.20.1", + "click>=8.3.1", + "gitpython>=3.1.45", + "httpx>=0.28.1", + "platformdirs>=4.10.0", + "pydantic>=2.13.4", +] -[tool.black] +[project.urls] +Homepage = "https://github.com/yurirocha15/leetcode2github" +Repository = "https://github.com/yurirocha15/leetcode2github" + +[project.scripts] +leet2git = "leet2git.leet2git:leet2git" + +[project.optional-dependencies] +dev = [ + "build>=1.3.0", + "pre-commit>=4.5.0", + "pytest>=9.1.1", + "pytest-cov>=7.1.0", + "pytest-custom-exit-code>=0.3.0", + "ruff>=0.15.18", + "ty>=0.0.51", +] + +[tool.setuptools.dynamic] +version = { attr = "leet2git.version.__version__" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +leet2git = ["py.typed"] + +[tool.ruff] line-length = 104 -target-version = ['py38'] -include = '\.pyi?$' -exclude = ''' -/( - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist - # The following are specific to Black, you probably don't want those. - | blib2to3 - | tests/data - | profiling -)/ -''' - -# --------- Isort ------------- - -[tool.isort] -profile = "black" -src_paths = ["src", "test"] - -# --------- Build-system ------------- +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "B", + "C4", + "E", + "F", + "I", + "N", + "UP", + "W", +] +ignore = [ + "E501", +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] [build-system] -requires = ["setuptools >= 40.6.0", "wheel"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["setuptools>=80.9.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt deleted file mode 100644 index f780c67..0000000 --- a/requirements/requirements-dev.txt +++ /dev/null @@ -1,22 +0,0 @@ -# pre-commit -pre-commit == 2.8.2 - -# linter -flake8 == 3.9.2 -flake8-builtins == 1.5.3 -flake8-docstrings == 1.5.0 -flake8-bugbear == 20.1.4 -flake8-polyfill == 1.0.2 -flake8-pytest == 1.3 -flake8-use-fstring == 1.1 -mypy == 0.790 -mypy-extensions == 0.4.3 -pep8-naming == 0.11.1 -pyflakes == 2.3.0 -pylint == 2.6.0 -pytest == 6.1.2 -pytest-cov == 2.10.1 -pytest-custom-exit-code == 0.3.0 -pytest-flake8 == 1.0.6 -pytest-mypy == 0.7.0 -pytest-pylint == 0.18.0 \ No newline at end of file diff --git a/requirements/requirements.txt b/requirements/requirements.txt deleted file mode 100644 index f97417e..0000000 --- a/requirements/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -appdirs == 1.4.4 -autoimport == 0.7.0 -black == 21.7b0 -beautifulsoup4 == 4.9.3 -browser-cookie3 == 0.12.1 -click == 8.0.1 -GitPython == 3.1.18 -isort == 5.9.3 -requests == 2.25.1 \ No newline at end of file diff --git a/scripts/smoke_leetcode_auth.py b/scripts/smoke_leetcode_auth.py new file mode 100644 index 0000000..24f49d1 --- /dev/null +++ b/scripts/smoke_leetcode_auth.py @@ -0,0 +1,104 @@ +"""Read-only smoke check for LeetCode browser-cookie authentication.""" + +import asyncio +from collections.abc import Mapping +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from leet2git.leetcode_client import LeetcodeClient +from leet2git.leetcode_models import SubmissionListResponse + + +class UserStatus(BaseModel): + """Authenticated user status returned by LeetCode globalData.""" + + model_config = ConfigDict(populate_by_name=True) + + is_signed_in: bool = Field(alias="isSignedIn") + username: str | None = None + + +class GlobalDataBody(BaseModel): + """globalData GraphQL data field.""" + + model_config = ConfigDict(populate_by_name=True) + + user_status: UserStatus = Field(alias="userStatus") + + +class GlobalDataResponse(BaseModel): + """globalData GraphQL response.""" + + data: GlobalDataBody + + +class EmptyVariables(BaseModel): + """Empty GraphQL variables.""" + + +class GlobalDataRequest(BaseModel): + """globalData GraphQL request.""" + + model_config = ConfigDict(populate_by_name=True) + + operation_name: Literal["globalData"] = Field(default="globalData", alias="operationName") + variables: EmptyVariables = Field(default_factory=EmptyVariables) + query: str = "query globalData { userStatus { isSignedIn username } }" + + +def print_mapping(name: str, value: Mapping[str, object]) -> None: + """Print small redacted dictionaries for manual smoke output.""" + print(f"{name}:") + for key, item in value.items(): + print(f" {key}: {item}") + + +async def main() -> None: + client = LeetcodeClient(timeout=20.0) + cookie_names = sorted( + cookie_pair.split("=", 1)[0] for cookie_pair in client.cookies.split("; ") if cookie_pair + ) + + print_mapping( + "cookie_discovery", + { + "cookie_names": cookie_names, + "leetcode_session_present": "LEETCODE_SESSION" in cookie_names, + "csrftoken_present": bool(client.csrftoken), + }, + ) + + user_status = await client._request_json( + "POST", + "https://leetcode.com/graphql", + GlobalDataResponse, + json_body=GlobalDataRequest(), + ) + status = user_status.data.user_status + print_mapping( + "user_status", + { + "keys": sorted(type(status).model_fields), + "is_signed_in": status.is_signed_in, + "username_present": bool(status.username), + }, + ) + + submissions = await client._request_json( + "GET", + "https://leetcode.com/api/submissions/?offset=0&limit=1&lastkey=", + SubmissionListResponse, + ) + print_mapping( + "submissions", + { + "keys": sorted(type(submissions).model_fields), + "submissions_dump_len": len(submissions.submissions_dump), + "has_next_type": type(submissions.has_next).__name__, + }, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/setup.py b/setup.py deleted file mode 100644 index ac6f651..0000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -import re -from glob import glob - -from setuptools import find_packages, setup - -with open("src/leet2git/version.py") as file: - version_match = re.search(r'__version__ = "(?P.*)"', file.read()) - if version_match is None: - raise ValueError("The version is not specified in the version.py file.") - version = version_match["version"] - -with open("README.md", "r") as readme_file: - readme = readme_file.read() - - -def pip(filename): - """Parse pip reqs file and transform it to setuptools requirements.""" - requirements = [] - with open(os.path.join("requirements", f"{filename}.txt"), "r") as file: - for line in file: - line = line.strip() - if not line or "://" in line or line.startswith("#"): - continue - requirements.append(line) - return requirements - - -install_require = pip("requirements") -dev_require = pip("requirements-dev") - -setup( - name="leet2git", - version=version, - description="Tool to ease the integration between Leetcode and a Git repository", - long_description=readme, - long_description_content_type="text/markdown", - url="https://github.com/yurirocha15/leetcode2github", - author="Yuri Rocha", - author_email="yurirocha15@gmail.com", - license="MIT License", - packages=find_packages("src"), - package_dir={"": "src"}, - package_data={"leet2git": ["py.typed"]}, - py_modules=[os.path.splitext(os.path.basename(path))[0] for path in glob("src/leet2git/*.py")], - python_requires=">=3.8", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: MacOS", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX", - "Operating System :: Unix", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Topic :: Utilities", - ], - install_requires=install_require, - dev_require=dev_require, - extras_require={ - "dev": dev_require, - }, - entry_points={ - "console_scripts": [ - "leet2git = leet2git.leet2git:leet2git", - ], - }, -) diff --git a/src/leet2git/config_manager.py b/src/leet2git/config_manager.py index 43a5d5d..5e4b82f 100644 --- a/src/leet2git/config_manager.py +++ b/src/leet2git/config_manager.py @@ -3,81 +3,129 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import json import os -from typing import Any, Dict, Optional -import appdirs import click +from platformdirs import PlatformDirs +from pydantic import BaseModel, ConfigDict, Field + + +class ReadmeConfig(BaseModel): + """README generation settings.""" + + model_config = ConfigDict(validate_assignment=True) + + show_difficulty: bool = True + show_category: bool = True + + +class SourceCodeConfig(BaseModel): + """Source file generation settings.""" + + model_config = ConfigDict(validate_assignment=True) + + add_description: bool = True + + +class TestCodeConfig(BaseModel): + """Test file generation settings.""" + + model_config = ConfigDict(validate_assignment=True) + + generate_tests: bool = True + + +class AppConfig(BaseModel): + """Validated leet2git configuration.""" + + model_config = ConfigDict(validate_assignment=True) + + language: str = "python3" + source_path: str = "" + legacy_data_path: str = "" + readme: ReadmeConfig = Field(default_factory=ReadmeConfig) + source_code: SourceCodeConfig = Field(default_factory=SourceCodeConfig) + test_code: TestCodeConfig = Field(default_factory=TestCodeConfig) + + +class ConfigOverrides(BaseModel): + """Typed command-line configuration overrides.""" + + model_config = ConfigDict(validate_assignment=True) + + language: str | None = None + source_path: str | None = None class ConfigManager: """Manages the user configuration files""" def __init__(self): - ad = appdirs.AppDirs(appname="leet2git", appauthor=False) - self._config_path = ad.user_config_dir - self._data_path = ad.user_data_dir + dirs = PlatformDirs(appname="leet2git", appauthor=False) + self._config_path = dirs.user_config_dir + self._legacy_data_path = dirs.user_data_dir self._config_file = os.path.join(self._config_path, "config.json") - self._config: Dict[str, Any] = None + self._config: AppConfig | None = None os.makedirs(self._config_path, exist_ok=True) - os.makedirs(self._data_path, exist_ok=True) + os.makedirs(self._legacy_data_path, exist_ok=True) if not os.path.isfile(self._config_file): - self.reset_config("") + self.reset_config("", edit=False) @property - def config(self) -> Dict[str, Any]: + def config(self) -> AppConfig: """The user configuration Returns: - Dict[str, Any]: the user configuration + AppConfig: the user configuration """ - config = self._config - if not config: - config = {} - return config + if not self._config: + return AppConfig(legacy_data_path=self._legacy_data_path) + return self._config - def load_config(self, override_config: Optional[Dict[str, Any]] = None): + def load_config(self, override_config: ConfigOverrides | None = None): """Loads the configuration Args: - override_config (Optional[Dict[str, Any]]): values that should be overriden. + override_config (Optional[ConfigOverrides]): values that should be overridden. Defaults to an empty dict. """ if not override_config: - override_config = {} - with open(self._config_file, "r") as file: - self._config = json.load(file) - self._config["data_path"] = self._data_path - for key, value in override_config.items(): - self._config[key] = value - - def reset_config(self, repo_path: str, language: str = "python3"): + override_config = ConfigOverrides() + with open(self._config_file) as file: + config = AppConfig.model_validate_json(file.read()) + self._config = config.model_copy( + update={ + "legacy_data_path": self._legacy_data_path, + **override_config.model_dump(exclude_none=True), + } + ) + + def reset_config(self, repo_path: str, language: str = "python3", edit: bool = True): """Resets the config and open it on the default editor Args: repo_path (str): the path to the folder where the code will be saved language (str, optional): the default language. Defaults to "python3". """ - self._config = { - "language": language, - "source_path": repo_path, - "readme": { - "show_difficulty": True, - "show_category": True, - }, - "source_code": { - "add_description": True, - }, - "test_code": { - "generate_tests": True, - }, - } + self._config = AppConfig( + language=language, + source_path=repo_path, + legacy_data_path=self._legacy_data_path, + ) with open(self._config_file, "w", encoding="UTF8") as file: - json.dump(self._config, file, indent=4) + json.dump( + self._config.model_dump(mode="json", exclude={"legacy_data_path"}), + file, + indent=4, + ) - click.edit(filename=self._config_file, extension=".json") - click.secho(f"You can also edit the configuration manually. File Location: {self._config_file}") + if edit: + click.edit(filename=self._config_file, extension=".json") + click.secho( + f"You can also edit the configuration manually. File Location: {self._config_file}" + ) if __name__ == "__main__": diff --git a/src/leet2git/default_handler.py b/src/leet2git/default_handler.py index 092f3dd..1cca831 100644 --- a/src/leet2git/default_handler.py +++ b/src/leet2git/default_handler.py @@ -3,9 +3,10 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import os -from typing import Any, Dict, List +from leet2git.config_manager import AppConfig from leet2git.file_handler import FileHandler from leet2git.question_db import QuestionData @@ -13,14 +14,14 @@ class DefaultHandler(FileHandler): """Generates the source files for languages without handlers""" - languages: List[str] = [] + languages: list[str] = [] def __init__(self) -> None: super().__init__() self.question_data: QuestionData = QuestionData() - self.config: Dict[str, Any] = {} + self.config: AppConfig = AppConfig() - def set_data(self, question_data: QuestionData, config: Dict[str, Any]): + def set_data(self, question_data: QuestionData, config: AppConfig): """Sets the data needed to generate the files Args: @@ -30,13 +31,13 @@ def set_data(self, question_data: QuestionData, config: Dict[str, Any]): self.question_data = question_data self.config = config - def get_function_name(self) -> List[str]: + def get_function_name(self) -> list[str]: """Returns the function name Returns: List[str]: a list with all function names """ - return list() + return [] def generate_source(self) -> str: """Generates the source file @@ -48,10 +49,10 @@ def generate_source(self) -> str: extension: str = self.conversions[self.question_data.language]["extension"] description = ( [comment + " " + line + "\n" for line in self.question_data.description] - if self.config["source_code"]["add_description"] + if self.config.source_code.add_description else [] ) - lines: List[str] = ( + lines: list[str] = ( [ comment + f" @l2g {self.question_data.id} {self.question_data.language}\n", comment + f" [{self.question_data.id}] {self.question_data.title}\n", @@ -72,15 +73,15 @@ def generate_source(self) -> str: ) lines.extend(code) self.question_data.file_path += extension + full_path = os.path.join(self.config.source_path, self.question_data.file_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) - with open( - os.path.join(self.config["source_path"], self.question_data.file_path), "w", encoding="UTF8" - ) as f: + with open(full_path, "w", encoding="UTF8") as f: f.writelines(lines) return self.question_data.file_path - def generete_tests(self) -> str: + def generate_tests(self) -> str: """Not Implemented""" return "" @@ -92,7 +93,7 @@ def generate_submission_file(self) -> str: """ code: str = "" with open( - os.path.join(self.config["source_path"], self.question_data.file_path), "r", encoding="UTF8" + os.path.join(self.config.source_path, self.question_data.file_path), encoding="UTF8" ) as f: for line in f: code += line diff --git a/src/leet2git/file_handler.py b/src/leet2git/file_handler.py index 6dd1bea..19e07f1 100644 --- a/src/leet2git/file_handler.py +++ b/src/leet2git/file_handler.py @@ -3,20 +3,19 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import os import signal -import traceback from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Type, TypeVar +from collections.abc import MutableMapping import click from git import Repo -from leet2git.leetcode_client import LeetcodeClient +from leet2git.config_manager import AppConfig +from leet2git.leetcode_client import LeetcodeAPIError, LeetcodeClient from leet2git.question_db import QuestionData -T = TypeVar("T", bound="FileHandler") - class FileHandler(ABC): """Abstract class for file handlers @@ -26,8 +25,8 @@ class FileHandler(ABC): languages (List[str]): List of languages this handler generates files to """ - languages: List[str] = [] - conversions: Dict[str, Dict[str, str]] = { + languages: list[str] = [] + conversions: dict[str, dict[str, str]] = { "bash": {"extension": ".sh", "comment": "#"}, "c": {"extension": ".c", "comment": "//"}, "cpp": {"extension": ".cpp", "comment": "//"}, @@ -47,7 +46,7 @@ class FileHandler(ABC): } @classmethod - def get_handler_type(cls: Type[T], config: Dict[str, Any]) -> Type[T]: + def get_handler_type(cls, config: AppConfig) -> type["FileHandler"]: """Returns the type of the correct subclass Args: @@ -57,13 +56,9 @@ def get_handler_type(cls: Type[T], config: Dict[str, Any]) -> Type[T]: Returns: Type[FileHandler]: the type of the correct subclass """ - subclasses: Dict[str, Type[T]] = { - language: subclass for subclass in cls.__subclasses__() for language in subclass.languages - } - subclass: Type[T] = DefaultHandler - if config["language"].lower() in subclasses: - subclass = subclasses[config["language"].lower()] - return subclass + from leet2git.handler_registry import get_handler_type + + return get_handler_type(config.language) def check_if_exists(self, language: str) -> bool: """Check if there is a handler for a given language @@ -88,7 +83,7 @@ def generate_repo(self, folder_path: str) -> None: os.makedirs(os.path.join(folder_path, "src"), exist_ok=True) @abstractmethod - def set_data(self, question_data: QuestionData, config: Dict[str, Any]) -> None: + def set_data(self, question_data: QuestionData, config: AppConfig) -> None: """Abstract method definition Raises: @@ -97,7 +92,7 @@ def set_data(self, question_data: QuestionData, config: Dict[str, Any]) -> None: raise NotImplementedError @abstractmethod - def get_function_name(self) -> str: + def get_function_name(self) -> list[str]: """Abstract method definition Raises: @@ -115,7 +110,7 @@ def generate_source(self) -> str: raise NotImplementedError @abstractmethod - def generete_tests(self) -> str: + def generate_tests(self) -> str: """Abstract method definition Raises: @@ -123,8 +118,12 @@ def generete_tests(self) -> str: """ raise NotImplementedError + def generete_tests(self) -> str: + """Backward-compatible misspelled alias for generate_tests.""" + return self.generate_tests() + @abstractmethod - def generate_submission_file(self) -> None: + def generate_submission_file(self) -> str: """Abstract method definition Raises: @@ -137,7 +136,7 @@ def generate_submission_file(self) -> None: # pylint: disable=abstract-class-instantiated -def create_file_handler(data: QuestionData, config: Dict[str, Any]) -> FileHandler: +def create_file_handler(data: QuestionData, config: AppConfig) -> FileHandler: """Create an instance of a File Handler Args: @@ -147,20 +146,20 @@ def create_file_handler(data: QuestionData, config: Dict[str, Any]) -> FileHandl Returns: FileHandler: [description] """ - handler_type: Type[FileHandler] = FileHandler.get_handler_type(config) + handler_type: type[FileHandler] = FileHandler.get_handler_type(config) file_handler = super(FileHandler, handler_type).__new__(handler_type) file_handler.set_data(data, config) return file_handler def generate_files( - args: Dict[int, QuestionData], + args: MutableMapping[int, QuestionData], qid: int, title_slug: str, lc: LeetcodeClient, timestamp: float, - config: Dict[str, Any], - code: Optional[str] = "", + config: AppConfig, + code: str | None = "", ) -> None: """Auxiliar function to generate the question files @@ -175,29 +174,22 @@ def generate_files( """ s = signal.signal(signal.SIGINT, signal.SIG_IGN) try: - data, is_new = lc.get_question_data(qid, title_slug, config["language"], code) - except ValueError as e: - click.secho(e.args, fg="red") - click.secho(traceback.format_exc()) + data, is_new = lc.get_question_data(qid, title_slug, config.language, code) + except (LeetcodeAPIError, ValueError) as e: + click.secho(str(e), fg="red") signal.signal(signal.SIGINT, s) return if is_new: # generate - data.language = config["language"] + data.language = config.language data.creation_time = timestamp file_handler = create_file_handler(data, config) data.function_name = file_handler.get_function_name() data.file_path = file_handler.generate_source() - if data.inputs and data.outputs: - data.test_file_path = file_handler.generete_tests() + if config.test_code.generate_tests and data.inputs and data.outputs: + data.test_file_path = file_handler.generate_tests() args[qid] = data click.secho(f"""The question "{qid}|{data.title}" was imported""") signal.signal(signal.SIGINT, s) - - -# child classes (need to be imported in order to be instantiated) -# pylint: disable=wrong-import-position disable=unused-import -from leet2git.default_handler import DefaultHandler # noqa -from leet2git.python_handler import PythonHandler # noqa diff --git a/src/leet2git/handler_registry.py b/src/leet2git/handler_registry.py new file mode 100644 index 0000000..cb622ef --- /dev/null +++ b/src/leet2git/handler_registry.py @@ -0,0 +1,14 @@ +"""Lazy file-handler registry.""" + +from leet2git.default_handler import DefaultHandler +from leet2git.file_handler import FileHandler +from leet2git.python_handler import PythonHandler + + +def get_handler_type(language: str) -> type[FileHandler]: + """Return the file-handler class for a language.""" + handlers: tuple[type[FileHandler], ...] = (PythonHandler,) + handler_map = { + handled_language: handler for handler in handlers for handled_language in handler.languages + } + return handler_map.get(language.lower(), DefaultHandler) diff --git a/src/leet2git/leet2git.py b/src/leet2git/leet2git.py index 8644f9b..d277648 100644 --- a/src/leet2git/leet2git.py +++ b/src/leet2git/leet2git.py @@ -3,21 +3,20 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import glob import os import time -import traceback from multiprocessing import Process from multiprocessing.managers import SyncManager -from typing import Any, Dict, List, Optional import click from click.core import Context from click.exceptions import Abort -from leet2git.config_manager import ConfigManager +from leet2git.config_manager import ConfigManager, ConfigOverrides from leet2git.file_handler import create_file_handler, generate_files -from leet2git.leetcode_client import LeetcodeClient +from leet2git.leetcode_client import LeetcodeAPIError, LeetcodeAuthError, LeetcodeClient from leet2git.my_utils import ( get_question_id, mgr_init, @@ -48,8 +47,8 @@ @click.pass_context def leet2git( ctx: Context, - source_repository: Optional[str] = "", - language: Optional[str] = "", + source_repository: str | None = "", + language: str | None = "", ): """Leet2Git App \f @@ -59,11 +58,11 @@ def leet2git( language (str): the programming language """ cm = ConfigManager() - override_config = {} + override_config = ConfigOverrides() if language: - override_config["language"] = language + override_config.language = language if source_repository: - override_config["source_path"] = source_repository + override_config.source_path = source_repository cm.load_config(override_config) ctx.obj = cm @@ -78,19 +77,25 @@ def get(cm: ConfigManager, question_id: int): question_id (int): the question question_id """ qdb: QuestionDB = QuestionDB(cm.config) - lc = LeetcodeClient() qdb.load() if qdb.check_if_exists(question_id): click.secho("Question already imported") return - if not qdb.check_if_slug_is_known(question_id): - qdb.set_id_title_map(lc.get_id_title_map()) - qdb.save() + try: + lc = LeetcodeClient() + if not qdb.check_if_slug_is_known(question_id): + qdb.set_id_title_map(lc.get_id_title_map()) + qdb.save() - # get question data - args: Dict[int, QuestionData] = {} - generate_files(args, question_id, qdb.get_title_from_id(question_id), lc, time.time(), cm.config) + # get question data + args: dict[int, QuestionData] = {} + generate_files( + args, question_id, qdb.get_title_from_id(question_id), lc, time.time(), cm.config + ) + except (LeetcodeAPIError, LeetcodeAuthError) as e: + click.secho(str(e), fg="red") + return if question_id in args: # store data @@ -114,24 +119,22 @@ def submit(cm: ConfigManager, question_id: int): qdb: QuestionDB = QuestionDB(cm.config) qdb.load() # create submit file - if qdb.check_if_exists(question_id): - file_handler = create_file_handler(qdb.get_question(question_id), cm.config) - code = file_handler.generate_submission_file() + question_data = qdb.get_question(question_id) + if not question_data: + click.secho(f"Could not find the question with id {question_id}") + return + file_handler = create_file_handler(question_data, cm.config) + code = file_handler.generate_submission_file() + + try: lc = LeetcodeClient() - try: - question_data = qdb.get_question(question_id) - title_slug = ( - question_data.title_slug - if question_data.title_slug - else qdb.get_title_from_id(question_id) - ) - lc.submit_question(code, question_data.internal_id, title_slug, cm.config["language"]) - except Exception as e: - click.secho(e.args, fg="red") - click.secho(traceback.format_exc()) - else: - click.secho(f"Could not find the question with id {question_id}") + title_slug = ( + question_data.title_slug if question_data.title_slug else qdb.get_title_from_id(question_id) + ) + lc.submit_question(code, question_data.internal_id, title_slug, cm.config.language) + except (LeetcodeAPIError, LeetcodeAuthError) as e: + click.secho(str(e), fg="red") @leet2git.command() @@ -146,32 +149,30 @@ def run(cm: ConfigManager, question_id: int): qdb: QuestionDB = QuestionDB(cm.config) qdb.load() # create test file - if qdb.check_if_exists(question_id): - file_handler = create_file_handler(qdb.get_question(question_id), cm.config) - code = file_handler.generate_submission_file() + question_data = qdb.get_question(question_id) + if not question_data: + click.secho(f"Could not find the question with id {question_id}") + return + file_handler = create_file_handler(question_data, cm.config) + code = file_handler.generate_submission_file() + + try: lc = LeetcodeClient() - try: - question_data = qdb.get_question(question_id) - title_slug = ( - question_data.title_slug - if question_data.title_slug - else qdb.get_title_from_id(question_id) - ) - raw_inputs = "\n".join(["\n".join(i.split(", ")) for i in question_data.inputs]) - lc.submit_question( - code, - question_data.internal_id, - title_slug, - cm.config["language"], - True, - raw_inputs, - ) - except Exception as e: - click.secho(e.args, fg="red") - click.secho(traceback.format_exc()) - else: - click.secho(f"Could not find the question with id {question_id}") + title_slug = ( + question_data.title_slug if question_data.title_slug else qdb.get_title_from_id(question_id) + ) + raw_inputs = "\n".join(["\n".join(i.split(", ")) for i in question_data.inputs]) + lc.submit_question( + code, + question_data.internal_id, + title_slug, + cm.config.language, + True, + raw_inputs, + ) + except (LeetcodeAPIError, LeetcodeAuthError) as e: + click.secho(str(e), fg="red") @leet2git.command() @@ -179,31 +180,26 @@ def run(cm: ConfigManager, question_id: int): def import_all(cm: ConfigManager): """Get all solutions and generate their files""" qdb: QuestionDB = QuestionDB(cm.config) - lc = LeetcodeClient() qdb.load() has_next: bool = True last_key: str = "" offset: int = 0 imported_cnt = 0 + manager: SyncManager | None = None try: + lc = LeetcodeClient() while has_next: - jobs: List[Process] = [] + jobs: list[Process] = [] manager = SyncManager() manager.start(mgr_init) - ret_dict: Dict[Any, Any] = manager.dict() + ret_dict = manager.dict() submissions = lc.get_submission_list(last_key, offset) - if "submissions_dump" not in submissions: - if imported_cnt <= 0: - raise ValueError( - "No submission to import. Are you logged in to leetcode? (Chrome or Firefox)" - ) - break - for submission in submissions["submissions_dump"]: - qid: int = get_question_id(submission["title_slug"], qdb, lc) + for submission in submissions.submissions_dump: + qid: int = get_question_id(submission.title_slug, qdb, lc) if ( - submission["status_display"] == "Accepted" - and submission["lang"] == cm.config["language"] + submission.status_display == "Accepted" + and submission.lang == cm.config.language and not qdb.check_if_exists(qid) ): # pre-store the question @@ -214,11 +210,11 @@ def import_all(cm: ConfigManager): args=( ret_dict, qid, - submission["title_slug"], + submission.title_slug, lc, - submission["timestamp"], + submission.timestamp, cm.config, - submission["code"], + submission.code, ), ) jobs.append(p) @@ -226,18 +222,20 @@ def import_all(cm: ConfigManager): imported_cnt += wait_to_finish_download(jobs, ret_dict, qdb) - has_next = submissions["has_next"] - last_key = submissions["last_key"] + has_next = submissions.has_next + last_key = submissions.last_key offset += 20 qdb.save() + if has_next: + time.sleep(1) except KeyboardInterrupt: click.secho("Stopping the process...") imported_cnt += wait_to_finish_download(jobs, ret_dict, qdb) - except Exception as e: - click.secho(e.args, fg="red") - click.secho(traceback.format_exc()) + except (LeetcodeAPIError, LeetcodeAuthError, ValueError) as e: + click.secho(str(e), fg="red") finally: - manager.shutdown() + if manager is not None: + manager.shutdown() qdb.save() # update readme @@ -261,8 +259,9 @@ def delete(cm: ConfigManager, question_id: int): if qdb.check_if_exists(question_id): data = qdb.get_data()[question_id] try: - os.remove(data.file_path) - os.remove(data.test_file_path) + os.remove(os.path.join(cm.config.source_path, data.file_path)) + if data.test_file_path: + os.remove(os.path.join(cm.config.source_path, data.test_file_path)) except FileNotFoundError as e: click.secho(e.args) qdb.delete_question(question_id) @@ -294,9 +293,9 @@ def init(cm: ConfigManager, source_repository: str, language: str, create_repo: reset_config(cm, source_repository, language, load_old=False) cm.load_config() if create_repo: - data = QuestionData(language=cm.config["language"]) + data = QuestionData(language=cm.config.language) file_handler = create_file_handler(data, cm.config) - file_handler.generate_repo(cm.config["source_path"]) + file_handler.generate_repo(cm.config.source_path) @leet2git.command() @@ -323,15 +322,15 @@ def reset(cm: ConfigManager, source_repository: str, language: str, soft: bool): try: click.confirm( f"This will delete EVERY solution and test file inside \ - the {cm.config['source_path']} folder. \ + the {cm.config.source_path} folder. \ Still want to proceed?", abort=True, ) except Abort: return - file_list = glob.glob(os.path.join(cm.config["source_path"], "src", "leetcode_*")) + glob.glob( - os.path.join(cm.config["source_path"], "tests", "test_*") + file_list = glob.glob(os.path.join(cm.config.source_path, "src", "leetcode_*")) + glob.glob( + os.path.join(cm.config.source_path, "tests", "test_*") ) for file in file_list: @@ -352,9 +351,9 @@ def reset(cm: ConfigManager, source_repository: str, language: str, soft: bool): qdb.reset() if not soft: - data = QuestionData(language=cm.config["language"]) + data = QuestionData(language=cm.config.language) file_handler = create_file_handler(data, cm.config) - file_handler.generate_repo(cm.config["source_path"]) + file_handler.generate_repo(cm.config.source_path) if __name__ == "__main__": diff --git a/src/leet2git/leetcode_client.py b/src/leet2git/leetcode_client.py index 7f91bcd..1eb290a 100644 --- a/src/leet2git/leetcode_client.py +++ b/src/leet2git/leetcode_client.py @@ -3,58 +3,98 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ -import json + +import asyncio import os import platform -import re +import textwrap import time -import traceback -from typing import Any, Dict, Optional, Tuple +from collections.abc import Coroutine +from http.cookiejar import Cookie, CookieJar +from typing import Any, TypeVar import browser_cookie3 import click -import requests +import httpx from bs4 import BeautifulSoup -from requests.models import Response - +from pydantic import BaseModel, ValidationError + +from leet2git.leetcode_models import ( + InterpretSolutionResponse, + ProblemListResponse, + QuestionDataRequest, + QuestionDataResponse, + QuestionDataVariables, + QuestionPayload, + SubmissionListResponse, + SubmissionResultResponse, + SubmitSolutionPayload, + SubmitSolutionResponse, +) from leet2git.question_db import IdTitleMap, QuestionData +LEETCODE_COOKIE_DOMAINS = {"leetcode.com", ".leetcode.com"} +USER_AGENT = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36" +) +ResponseModel = TypeVar("ResponseModel", bound=BaseModel) + + +class LeetcodeAuthError(RuntimeError): + """Raised when local browser cookies cannot authenticate with LeetCode.""" + + +class LeetcodeAPIError(RuntimeError): + """Raised when LeetCode returns an unexpected response or request failure.""" + class LeetcodeClient: """Handles getting data from leetcode""" - def __init__(self): + def __init__( + self, + *, + transport: httpx.AsyncBaseTransport | None = None, + timeout: float = 30.0, + ): os_name = platform.system() if os_name in ["Linux", "Darwin"]: self.divider = "/" elif os_name == "Windows": self.divider = "\\" + self._transport = transport + self._timeout = timeout self.cookies, self.csrftoken = self.get_cookies() - def get_cookies(self) -> Tuple[str, str]: + def get_cookies(self) -> tuple[str, str]: """Get the cookies from the browser Returns: Tuple[str, str]: the raw cookies and the csrftoken """ - url: str = "https://leetcode.com/profile/" - client = requests.session() browsers = (browser_cookie3.chrome, browser_cookie3.firefox) for browser in browsers: try: - r = client.get(url, cookies=browser()) - cookies = r.request.headers["Cookie"] - csrftoken = client.cookies["csrftoken"] + cookie_jar = browser() except browser_cookie3.BrowserCookieError as e: click.secho(e.args, fg="red") - if csrftoken: - break + continue + + leetcode_cookies = self._get_leetcode_cookies(cookie_jar) + if self._has_cookie(leetcode_cookies, "LEETCODE_SESSION"): + return self._build_cookie_header(leetcode_cookies), self._get_cookie_value( + leetcode_cookies, "csrftoken" + ) - return cookies, csrftoken + raise LeetcodeAuthError( + "Could not find a LeetCode login session in Chrome or Firefox. " + "Log in to https://leetcode.com in a local browser and try again." + ) - def get_headers(self) -> Dict[str, str]: + def get_headers(self) -> dict[str, str]: """Return the headers needed to call leetcode api Returns: @@ -70,15 +110,17 @@ def get_headers(self) -> Dict[str, str]: "accept": "*/*", "referer": "https://leetcode.com", "origin": "https://leetcode.com", - "x-csrftoken": self.csrftoken, + "user-agent": USER_AGENT, "cookie": self.cookies, } + if self.csrftoken: + headers["x-csrftoken"] = self.csrftoken return headers def get_question_data( - self, question_id: int, title_slug: str, language: str, code: Optional[str] = "" - ) -> Tuple[QuestionData, bool]: + self, question_id: int, title_slug: str, language: str, code: str | None = "" + ) -> tuple[QuestionData, bool]: """Gets the data from a question Args: @@ -92,41 +134,33 @@ def get_question_data( """ leetcode_question_data = self.scrap_question_data(title_slug) + question = self._get_question_payload(leetcode_question_data, title_slug) data = QuestionData(id=question_id, creation_time=time.time()) - data.internal_id = int(leetcode_question_data["data"]["question"]["questionId"]) - data.title = leetcode_question_data["data"]["question"]["title"] - data.title_slug = leetcode_question_data["data"]["question"]["titleSlug"] - data.url = ( - "https://leetcode.com/problems/" + leetcode_question_data["data"]["question"]["titleSlug"] - ) - data.difficulty = leetcode_question_data["data"]["question"]["difficulty"] - data.question_template = next( - code["code"] - for code in leetcode_question_data["data"]["question"]["codeSnippets"] - if code["langSlug"] == language - ) - data.categories = leetcode_question_data["data"]["question"]["topicTags"] - - # fix #24. 105 becomes 10^5 - soup = BeautifulSoup( - re.sub( - r"(?:\)(\d+)(?:\<\/sup\>)", - r"^\1", - leetcode_question_data["data"]["question"]["content"], - ), - features="html.parser", - ) + data.internal_id = int(question.question_id) + data.title = question.title + data.title_slug = question.title_slug + data.url = "https://leetcode.com/problems/" + data.title_slug + data.difficulty = question.difficulty + data.question_template = self._get_code_snippet(question, language) + data.categories = question.topic_tags + + soup = BeautifulSoup(question.content, features="html.parser") + for sup in soup.find_all("sup"): + sup.string = "^" + sup.get_text() data.description = soup.get_text().replace("\r\n", "\n").split("\n") - num_of_inputs = len(leetcode_question_data["data"]["question"]["sampleTestCase"].split("\n")) - inputs = leetcode_question_data["data"]["question"]["exampleTestcases"].split("\n") + sample_test_case = question.sample_test_case + example_test_cases = question.example_testcases + num_of_inputs = len(sample_test_case.split("\n")) + inputs = example_test_cases.split("\n") data.inputs = [ ", ".join(inputs[i : i + num_of_inputs]) for i in range(0, len(inputs), num_of_inputs) ] tmp_description = [] example_started = False for idx, line in enumerate(data.description): - if re.match(r"\s*Example\s*[0-9]*:", line): + stripped_line = line.strip() + if stripped_line.startswith("Example") and stripped_line.endswith(":"): example_started = True elif "Output: " in line and example_started: data.outputs.append(line[8:]) @@ -135,25 +169,14 @@ def get_question_data( data.outputs.append(data.description[idx + 1].strip()) example_started = False if len(line) > 100: - # split on commas or periods, while keeping them - split_line = re.split(r"(?<=[\.\,])\s*", line) - tmp_line = "" - for phrase in split_line: - if not tmp_line or len(tmp_line) + len(phrase) <= 100: - tmp_line += phrase - else: - tmp_description.append(tmp_line) - tmp_line = phrase - if tmp_line: - tmp_description.append(tmp_line) + tmp_description.extend(textwrap.wrap(line, width=100, break_long_words=False)) else: tmp_description.append(line) data.description = tmp_description data.file_path = os.path.join( "src", - f"leetcode_{data.id}_" - + leetcode_question_data["data"]["question"]["titleSlug"].replace("-", "_"), + f"leetcode_{data.id}_" + data.title_slug.replace("-", "_"), ) if code: @@ -161,7 +184,43 @@ def get_question_data( return data, True - def scrap_question_data(self, question_name: str) -> Dict[str, Dict[str, Any]]: + def get_latest_submission(self, qid: str, language: str) -> str: + """Get the latest stored submission code for a question/language.""" + return self._run_async(self.async_get_latest_submission(qid, language)) + + async def async_get_latest_submission(self, qid: str, language: str) -> str: + """Get the latest stored submission code without using stale LeetCode endpoints.""" + try: + question_id = int(qid) + except ValueError as e: + raise LeetcodeAPIError(f'Question id "{qid}" is not numeric.') from e + + id_title_map = await self.async_get_id_title_map() + title_slug = id_title_map.id_to_title.get(question_id) + if not title_slug: + raise LeetcodeAPIError(f'LeetCode problem list did not include question "{qid}".') + + has_next = True + last_key = "" + offset = 0 + while has_next: + submissions = await self.async_get_submission_list(last_key, offset) + for submission in submissions.submissions_dump: + if submission.title_slug == title_slug and submission.lang == language: + return submission.code + has_next = submissions.has_next + last_key = submissions.last_key + offset += 20 + + raise LeetcodeAPIError( + f'Could not find a "{language}" submission for question "{qid}" in LeetCode history.' + ) + + def scrap_question_data(self, question_name: str) -> QuestionDataResponse: + """Query question information using the async HTTP implementation.""" + return self._run_async(self.async_scrap_question_data(question_name)) + + async def async_scrap_question_data(self, question_name: str) -> QuestionDataResponse: """Query a question information Args: @@ -172,80 +231,53 @@ def scrap_question_data(self, question_name: str) -> Dict[str, Dict[str, Any]]: """ url: str = "https://leetcode.com/graphql" - payload: str = json.dumps( - { - "operationName": "questionData", - "variables": {"titleSlug": question_name}, - "query": "query questionData($titleSlug: String) {\n question(titleSlug: $titleSlug) {\ - \n questionId\ - \n questionFrontendId\ - \n title\ - \n titleSlug\ - \n content\ - \n isPaidOnly\ - \n difficulty\ - \n likes\ - \n dislikes\ - \n exampleTestcases\ - \n topicTags {\ - \n name\ - \n slug\ - \n translatedName\ - \n __typename\ - \n }\ - \n codeSnippets {\ - \n lang\ - \n langSlug\ - \n code\ - \n __typename\ - \n }\ - \n stats\ - \n hints\ - \n solution {\ - \n id\ - \n canSeeDetail\ - \n paidOnly\ - \n hasVideoSolution\ - \n paidOnlyVideo\ - \n __typename\ - \n }\ - \n status\ - \n sampleTestCase\ - \n metaData\ - \n __typename\n }\n}\n", - } + payload = QuestionDataRequest( + variables=QuestionDataVariables(titleSlug=question_name), + query="query questionData($titleSlug: String) {\n question(titleSlug: $titleSlug) {\ + \n questionId\ + \n questionFrontendId\ + \n title\ + \n titleSlug\ + \n content\ + \n isPaidOnly\ + \n difficulty\ + \n likes\ + \n dislikes\ + \n exampleTestcases\ + \n topicTags {\ + \n name\ + \n slug\ + \n translatedName\ + \n __typename\ + \n }\ + \n codeSnippets {\ + \n lang\ + \n langSlug\ + \n code\ + \n __typename\ + \n }\ + \n stats\ + \n hints\ + \n solution {\ + \n id\ + \n canSeeDetail\ + \n paidOnly\ + \n hasVideoSolution\ + \n paidOnlyVideo\ + \n __typename\ + \n }\ + \n status\ + \n sampleTestCase\ + \n metaData\ + \n __typename\n }\n}\n", ) - response: Response = requests.request("POST", url, headers=self.get_headers(), data=payload) - return json.loads(response.text) - - def get_latest_submission(self, qid: str, language: str) -> str: - """Get the latest submission for a question - - Args: - qid (str): the question id - cookies (str): leetcode cookies - language (str): the code language - - Returns: - str: the submitted code - """ - url: str = f"https://leetcode.com/submissions/latest/?qid={qid}&lang={language}" - - payload: str = "" - raw_code: str = "" - try: - response: Response = requests.request("GET", url, headers=self.get_headers(), data=payload) - raw_code = json.loads(response.text)["code"] - except RuntimeError as e: - click.secho(e.args, fg="red") - click.secho(traceback.format_exc()) - return raw_code + return await self._request_json("POST", url, QuestionDataResponse, json_body=payload) def submit_question( self, code: str, - internal_id: str, + internal_id: int, title_slug: str, language: str, is_test: bool = False, @@ -255,67 +287,105 @@ def submit_question( Args: code (str): the code which will be submitted - internal_id (str): the question "questionId". (different from "frontend_id") + internal_id (int): the question "questionId". (different from "frontend_id") title_slug (str): the question title slug language (str): the language of the code is_test (bool): if true, do not submit, only test on leetcode servers test_input (str): input to test. Only used if is_test is True """ + self._run_async( + self.async_submit_question(code, internal_id, title_slug, language, is_test, test_input) + ) + async def async_submit_question( + self, + code: str, + internal_id: int, + title_slug: str, + language: str, + is_test: bool = False, + test_input: str = "", + ) -> None: + """Submit or test a question asynchronously.""" + if not self.csrftoken: + raise LeetcodeAuthError( + "LeetCode did not expose a csrftoken cookie. Refusing to call a mutating " + "submit/run endpoint because the current LeetCode auth flow may have changed." + ) url: str = f"https://leetcode.com/problems/{title_slug}/" + ( "interpret_solution/" if is_test else "submit/" ) - payload_dict: Dict[str, str] = { - "question_id": internal_id, - "lang": language, - "typed_code": code, - } + payload = SubmitSolutionPayload(question_id=internal_id, lang=language, typed_code=code) if is_test: - payload_dict["data_input"] = test_input - payload_dict["judge_type"] = "large" + payload.data_input = test_input + payload.judge_type = "large" - payload: str = json.dumps(payload_dict) - response: Response = requests.request("POST", url, headers=self.get_headers(), data=payload) - submission_field: str = "interpret_id" if is_test else "submission_id" - submission_id: int = json.loads(response.text)[submission_field] + if is_test: + submission_response = await self._request_json( + "POST", + url, + InterpretSolutionResponse, + json_body=payload, + ) + submission_id = submission_response.interpret_id + else: + submission_response = await self._request_json( + "POST", + url, + SubmitSolutionResponse, + json_body=payload, + ) + submission_id = submission_response.submission_id click.secho("Waiting for submission results...") url = f"https://leetcode.com/submissions/detail/{submission_id}/check/" - payload = "" + submission_result: SubmissionResultResponse | None = None status: str = "" while status != "SUCCESS": - response = requests.request("GET", url, headers=self.get_headers(), data=payload) - submission_result: Dict[str, Any] = json.loads(response.text) - status = submission_result["state"] - time.sleep(1) + submission_result = await self._request_json("GET", url, SubmissionResultResponse) + status = submission_result.state + await asyncio.sleep(1) + + if submission_result is None: + raise LeetcodeAPIError("LeetCode did not return a submission result.") click.clear() - click.secho(f'Result: {submission_result["status_msg"]}') - if submission_result["status_code"] == 10: + click.secho(f"Result: {submission_result.status_msg or 'Unknown'}") + status_code = submission_result.status_code + if status_code == 10: click.secho( - f'Total Runtime: {submission_result["status_runtime"]} ' - + ("" if is_test else f'(Better than {submission_result["runtime_percentile"]:.2f}%)') + f"Total Runtime: {submission_result.status_runtime or 'unknown'} " + + ("" if is_test else f"(Better than {submission_result.runtime_percentile or 0:.2f}%)") ) click.secho( - f'Total Memory: {submission_result["status_memory"]} ' - + ("" if is_test else f'(Better than {submission_result["memory_percentile"]:.2f}%)') + f"Total Memory: {submission_result.status_memory or 'unknown'} " + + ("" if is_test else f"(Better than {submission_result.memory_percentile or 0:.2f}%)") ) - elif submission_result["status_code"] == 11: - click.secho(f'Last Input: {submission_result["input_formatted"]}') - click.secho(f'Expected Output: {submission_result["expected_output"]}') - click.secho(f'Code Output: {submission_result["code_output"]}') - elif submission_result["status_code"] == 14: + elif status_code == 11: + click.secho(f"Last Input: {submission_result.input_formatted or 'unknown'}") + click.secho(f"Expected Output: {submission_result.expected_output or 'unknown'}") + click.secho(f"Code Output: {submission_result.code_output or 'unknown'}") + elif status_code == 14: nl = "\n" - click.secho(f'Last Input: {submission_result["last_testcase"].replace(nl, " ")}') - click.secho(f'Expected Output: {submission_result["expected_output"]}') - click.secho(f'Code Output: {submission_result["code_output"]}') - elif submission_result["status_code"] == 15: - click.secho(f'Runtime Error: {submission_result["runtime_error"]}') - elif submission_result["status_code"] == 20: - click.secho(f'Compile Error: {submission_result["compile_error"]}') - - def get_submission_list(self, last_key: str = "", offset: int = 0) -> Dict[str, Any]: + last_testcase = (submission_result.last_testcase or "unknown").replace(nl, " ") + click.secho(f"Last Input: {last_testcase}") + click.secho(f"Expected Output: {submission_result.expected_output or 'unknown'}") + click.secho(f"Code Output: {submission_result.code_output or 'unknown'}") + elif status_code == 15: + click.secho(f"Runtime Error: {submission_result.runtime_error or 'unknown'}") + elif status_code == 20: + click.secho(f"Compile Error: {submission_result.compile_error or 'unknown'}") + + def get_submission_list(self, last_key: str = "", offset: int = 0) -> SubmissionListResponse: + """Get a list with 20 submissions using the async HTTP implementation.""" + return self._run_async(self.async_get_submission_list(last_key, offset)) + + async def async_get_submission_list( + self, + last_key: str = "", + offset: int = 0, + ) -> SubmissionListResponse: """Get a list with 20 submissions Args: @@ -327,13 +397,21 @@ def get_submission_list(self, last_key: str = "", offset: int = 0) -> Dict[str, """ url: str = f"https://leetcode.com/api/submissions/?offset={offset}&limit=20&lastkey={last_key}" - payload: str = "" - - response: Response = requests.request("GET", url, headers=self.get_headers(), data=payload) + for attempt in range(3): + try: + return await self._request_json("GET", url, SubmissionListResponse) + except LeetcodeAPIError as e: + if "HTTP 403" not in str(e) or attempt == 2: + raise + await asyncio.sleep(5 * (attempt + 1)) - return json.loads(response.text) + raise LeetcodeAPIError(f"Could not fetch LeetCode submissions page: {url}") def get_id_title_map(self) -> IdTitleMap: + """Get id/title mappings using the async HTTP implementation.""" + return self._run_async(self.async_get_id_title_map()) + + async def async_get_id_title_map(self) -> IdTitleMap: """Get a dictionary that maps the id to the question title slug Returns: @@ -341,23 +419,94 @@ def get_id_title_map(self) -> IdTitleMap: """ url: str = "https://leetcode.com/api/problems/all/" - payload: str = "" + id_title_map: IdTitleMap = IdTitleMap() + response = await self._request_json("GET", url, ProblemListResponse) + for pair in response.stat_status_pairs: + id_title_map.id_to_title[pair.stat.frontend_question_id] = pair.stat.question_title_slug + id_title_map.title_to_id[pair.stat.question_title_slug] = pair.stat.frontend_question_id - response: Response = requests.request("GET", url, headers=self.get_headers(), data=payload) + return id_title_map - id_title_map: IdTitleMap = IdTitleMap() - for stat in json.loads(response.text)["stat_status_pairs"]: - if "frontend_question_id" in stat["stat"] and "question__title_slug" in stat["stat"]: - id_title_map.id_to_title[int(stat["stat"]["frontend_question_id"])] = stat["stat"][ - "question__title_slug" - ] - id_title_map.title_to_id[stat["stat"]["question__title_slug"]] = int( - stat["stat"]["frontend_question_id"] + async def _request_json( + self, + method: str, + url: str, + model_type: type[ResponseModel], + *, + json_body: BaseModel | None = None, + ) -> ResponseModel: + """Send an authenticated LeetCode request and decode its JSON response.""" + async with httpx.AsyncClient( + headers=self.get_headers(), + timeout=self._timeout, + transport=self._transport, + follow_redirects=True, + ) as client: + try: + body = ( + json_body.model_dump(mode="json", by_alias=True, exclude_none=True) + if json_body + else None ) + response = await client.request(method, url, json=body) + response.raise_for_status() + payload = response.json() + except httpx.HTTPStatusError as e: + raise LeetcodeAPIError( + f"LeetCode request failed with HTTP {e.response.status_code}: {url}" + ) from e + except httpx.RequestError as e: + raise LeetcodeAPIError(f"Could not reach LeetCode: {e}") from e + except ValueError as e: + raise LeetcodeAPIError(f"LeetCode returned a non-JSON response: {url}") from e - return id_title_map + try: + return model_type.model_validate(payload) + except ValidationError as e: + raise LeetcodeAPIError(f"LeetCode returned unexpected JSON for {url}: {e}") from e + def _run_async(self, coroutine: Coroutine[Any, Any, Any]) -> Any: + """Run an async client method from synchronous Click commands.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coroutine) + coroutine.close() + raise LeetcodeAPIError("Cannot call synchronous LeetCode APIs from an active event loop.") -if __name__ == "__main__": - lc = LeetcodeClient() - print(lc.get_question_data(1848, "minimum-distance-to-the-target-element", "python")) + def _get_question_payload( + self, + response: QuestionDataResponse, + title_slug: str, + ) -> QuestionPayload: + """Validate and return the question payload from GraphQL.""" + question = response.data.question + if question is None: + raise LeetcodeAPIError(f'LeetCode could not find question "{title_slug}".') + return question + + def _get_code_snippet(self, question: QuestionPayload, language: str) -> str: + """Return the LeetCode starter code for a language.""" + for snippet in question.code_snippets: + if snippet.lang_slug == language: + return snippet.code + raise LeetcodeAPIError(f'LeetCode did not return a "{language}" code snippet.') + + def _get_leetcode_cookies(self, cookie_jar: CookieJar) -> list[Cookie]: + """Return browser cookies scoped to LeetCode.""" + return [cookie for cookie in cookie_jar if cookie.domain in LEETCODE_COOKIE_DOMAINS] + + def _has_cookie(self, cookies: list[Cookie], name: str) -> bool: + """Check whether a LeetCode cookie exists.""" + return any(cookie.name == name and bool(cookie.value) for cookie in cookies) + + def _get_cookie_value(self, cookies: list[Cookie], name: str) -> str: + """Return a LeetCode cookie value without logging it.""" + for cookie in cookies: + if cookie.name == name: + return cookie.value or "" + return "" + + def _build_cookie_header(self, cookies: list[Cookie]) -> str: + """Build a Cookie header from browser cookies without a profile-page request.""" + return "; ".join(f"{cookie.name}={cookie.value}" for cookie in cookies if cookie.value) diff --git a/src/leet2git/leetcode_models.py b/src/leet2git/leetcode_models.py new file mode 100644 index 0000000..139356a --- /dev/null +++ b/src/leet2git/leetcode_models.py @@ -0,0 +1,145 @@ +"""Typed LeetCode API request and response models.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from leet2git.question_db import TopicTag + + +class QuestionDataVariables(BaseModel): + """Variables for the questionData GraphQL query.""" + + title_slug: str = Field(alias="titleSlug") + + +class QuestionDataRequest(BaseModel): + """GraphQL questionData request body.""" + + model_config = ConfigDict(populate_by_name=True) + + operation_name: Literal["questionData"] = Field(default="questionData", alias="operationName") + variables: QuestionDataVariables + query: str + + +class CodeSnippet(BaseModel): + """Starter code returned by LeetCode for one language.""" + + model_config = ConfigDict(populate_by_name=True) + + lang: str = "" + lang_slug: str = Field(alias="langSlug") + code: str + typename: str | None = Field(default=None, alias="__typename") + + +class QuestionPayload(BaseModel): + """Question payload returned by the questionData query.""" + + model_config = ConfigDict(populate_by_name=True) + + question_id: str = Field(alias="questionId") + title: str + title_slug: str = Field(alias="titleSlug") + content: str + difficulty: str + example_testcases: str = Field(alias="exampleTestcases") + sample_test_case: str = Field(alias="sampleTestCase") + topic_tags: list[TopicTag] = Field(alias="topicTags") + code_snippets: list[CodeSnippet] = Field(alias="codeSnippets") + + +class QuestionDataBody(BaseModel): + """Nested data field for questionData.""" + + question: QuestionPayload | None + + +class QuestionDataResponse(BaseModel): + """Root questionData GraphQL response.""" + + data: QuestionDataBody + + +class SubmissionRow(BaseModel): + """Submission row returned by the submissions list endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + title_slug: str + status_display: str + lang: str + timestamp: float + code: str + + +class SubmissionListResponse(BaseModel): + """Submissions list response.""" + + submissions_dump: list[SubmissionRow] + has_next: bool + last_key: str + + +class ProblemStat(BaseModel): + """Problem stat entry returned by the problem-list endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + frontend_question_id: int + question_title_slug: str = Field(alias="question__title_slug") + + +class ProblemStatusPair(BaseModel): + """Problem list row.""" + + stat: ProblemStat + + +class ProblemListResponse(BaseModel): + """Problem-list endpoint response.""" + + stat_status_pairs: list[ProblemStatusPair] + + +class SubmitSolutionPayload(BaseModel): + """Payload for submit and interpret endpoints.""" + + model_config = ConfigDict(populate_by_name=True) + + question_id: int + lang: str + typed_code: str + data_input: str | None = None + judge_type: str | None = None + + +class SubmitSolutionResponse(BaseModel): + """Submit endpoint response.""" + + submission_id: int + + +class InterpretSolutionResponse(BaseModel): + """Interpret endpoint response.""" + + interpret_id: str + + +class SubmissionResultResponse(BaseModel): + """Polling response for a submitted or interpreted solution.""" + + state: str + status_msg: str | None = None + status_code: int | None = None + status_runtime: str | None = None + status_memory: str | None = None + runtime_percentile: float | None = None + memory_percentile: float | None = None + input_formatted: str | None = None + expected_output: str | None = None + code_output: str | list[str] | None = None + last_testcase: str | None = None + runtime_error: str | None = None + compile_error: str | None = None diff --git a/src/leet2git/my_utils.py b/src/leet2git/my_utils.py index 9d92520..e8f2766 100644 --- a/src/leet2git/my_utils.py +++ b/src/leet2git/my_utils.py @@ -3,14 +3,15 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import os import signal +from collections.abc import Mapping from multiprocessing import Process -from typing import Any, Dict, List from leet2git.config_manager import ConfigManager from leet2git.leetcode_client import LeetcodeClient -from leet2git.question_db import QuestionDB +from leet2git.question_db import QuestionData, QuestionDB def mgr_init(): @@ -28,7 +29,7 @@ def reset_config(cm: ConfigManager, source_repository: str, language: str, load_ load_old (bool, optional): If true load old value from config. Defaults to True. """ if not source_repository and load_old: - source_repository = cm.config["source_path"] + source_repository = cm.config.source_path if not source_repository: source_repository = os.getcwd() cm.reset_config(source_repository, language) @@ -55,7 +56,9 @@ def get_question_id(title_slug: str, qdb: QuestionDB, lc: LeetcodeClient) -> int return qid -def wait_to_finish_download(jobs: List[Process], ret_dict: Dict[Any, Any], qdb: QuestionDB) -> int: +def wait_to_finish_download( + jobs: list[Process], ret_dict: Mapping[object, QuestionData], qdb: QuestionDB +) -> int: """Wait until every subprocess finishes Args: diff --git a/src/leet2git/py.typed b/src/leet2git/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/leet2git/py.typed @@ -0,0 +1 @@ + diff --git a/src/leet2git/python_handler.py b/src/leet2git/python_handler.py index aa7a0f3..d71a875 100644 --- a/src/leet2git/python_handler.py +++ b/src/leet2git/python_handler.py @@ -3,16 +3,18 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import ast +import io import os -import re +import shutil import subprocess -import traceback -from typing import Any, Dict, List +import tokenize import click from autoimport import fix_files +from leet2git.config_manager import AppConfig from leet2git.file_handler import FileHandler from leet2git.question_db import QuestionData @@ -20,14 +22,14 @@ class PythonHandler(FileHandler): """Generates the source and test python files""" - languages: List[str] = ["python", "python3"] + languages: list[str] = ["python", "python3"] def __init__(self) -> None: super().__init__() self.question_data: QuestionData = QuestionData() - self.config: Dict[str, Any] = {} + self.config: AppConfig = AppConfig() - def set_data(self, question_data: QuestionData, config: Dict[str, Any]): + def set_data(self, question_data: QuestionData, config: AppConfig): """Sets the data needed to generate the files Args: @@ -37,17 +39,15 @@ def set_data(self, question_data: QuestionData, config: Dict[str, Any]): self.question_data = question_data self.config = config - def get_function_name(self) -> List[str]: + def get_function_name(self) -> list[str]: """Returns the function name Returns: List[str]: a list with all function names """ - functions: List[str] = re.findall( - r"[^#\s*]\s+def\s+(.*?)\(self,", self.question_data.question_template - ) - if functions[0] == "__init__": - functions[0] = re.findall(r"^class\s+(.*?):", self.question_data.question_template)[0] + functions = self._get_template_callables(self.question_data.question_template) + if not functions: + raise ValueError("Could not find a Python function in the LeetCode code template.") self.question_data.function_name = functions return functions @@ -61,10 +61,10 @@ def generate_source(self) -> str: extension: str = self.conversions[self.question_data.language]["extension"] description = ( [comment + " " + line + "\n" for line in self.question_data.description] - if self.config["source_code"]["add_description"] + if self.config.source_code.add_description else [] ) - lines: List[str] = ( + lines: list[str] = ( [ comment + f" @l2g {self.question_data.id} {self.question_data.language}\n", comment + f" [{self.question_data.id}] {self.question_data.title}\n", @@ -87,7 +87,8 @@ def generate_source(self) -> str: lines.extend(code_lines) self.question_data.file_path += extension - full_path: str = os.path.join(self.config["source_path"], self.question_data.file_path) + full_path: str = os.path.join(self.config.source_path, self.question_data.file_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, "w", encoding="UTF8") as f: f.writelines(lines) @@ -96,23 +97,23 @@ def generate_source(self) -> str: with open(full_path, "r+", encoding="UTF8") as f: fix_files((f,)) - # add main - with open(full_path, "a", encoding="UTF8") as f: - f.write("\n") - f.write("\n") - f.write('if __name__ == "__main__":\n') - f.write(" import pytest\n") - f.write(" import os\n") - f.write( - f" pytest.main([os.path.join('tests', 'test_{self.question_data.id}{extension}')])\n" - ) - f.write("") + if self.config.test_code.generate_tests: + with open(full_path, "a", encoding="UTF8") as f: + f.write("\n") + f.write("\n") + f.write('if __name__ == "__main__":\n') + f.write(" import pytest\n") + f.write(" import os\n") + f.write( + f" pytest.main([os.path.join('tests', 'test_{self.question_data.id}{extension}')])\n" + ) + f.write("") - self.run_black_and_isort(full_path) + self.run_formatter(full_path) return self.question_data.file_path - def generete_tests(self) -> str: + def generate_tests(self) -> str: """Generates the test file Returns: @@ -132,7 +133,9 @@ def generete_tests(self) -> str: if len(self.question_data.function_name) > 1: inputs = [] outputs = [] - for q_input, q_output in zip(self.question_data.inputs, self.question_data.outputs): + for q_input, q_output in zip( + self.question_data.inputs, self.question_data.outputs, strict=True + ): tmp_inputs = q_input.split(", ") inputs.append([]) for tmp_input in tmp_inputs: @@ -141,8 +144,9 @@ def generete_tests(self) -> str: elif not self.question_data.function_name: raise ValueError("No function name") full_path: str = os.path.join( - self.config["source_path"], "tests", f"test_{self.question_data.id}{extension}" + self.config.source_path, "tests", f"test_{self.question_data.id}{extension}" ) + os.makedirs(os.path.dirname(full_path), exist_ok=True) with open( full_path, "a", @@ -184,7 +188,7 @@ def generete_tests(self) -> str: f.write(f" yield _init_variables_{self.question_data.id}\n") f.write("\n") f.write(f"class TestClass{self.question_data.id}:") - for i, (q_input, q_output) in enumerate(zip(inputs, outputs)): + for i, (q_input, q_output) in enumerate(zip(inputs, outputs, strict=True)): f.write("\n") f.write(f" def test_solution_{i}(self, init_variables_{self.question_data.id}):\n") if len(self.question_data.function_name) == 1: @@ -198,7 +202,7 @@ def generete_tests(self) -> str: ) else: for input_func, input_val, output in zip( - q_input[0][1:], q_input[1][1:], q_output[1:] + q_input[0][1:], q_input[1][1:], q_output[1:], strict=True ): f.write( " assert" @@ -209,7 +213,7 @@ def generete_tests(self) -> str: + "\n" ) - self.run_black_and_isort(full_path) + self.run_formatter(full_path) return os.path.join("tests", f"test_{self.question_data.id}{extension}") @@ -219,17 +223,20 @@ def generate_submission_file(self) -> str: Returns: str: a string containing the code """ - code: str = "" - # regex to match main definition - match = r"""^if\s+__name__\s+==\s+('|")__main__('|")\s*:\s*""" - full_path: str = os.path.join(self.config["source_path"], self.question_data.file_path) - with open(full_path, "r", encoding="UTF8") as f: - for line in f: - if re.match(match, line): - break - code += line - - return code + full_path: str = os.path.join(self.config.source_path, self.question_data.file_path) + with open(full_path, encoding="UTF8") as f: + source = f.read() + + try: + tree = ast.parse(source) + except SyntaxError: + return source + + main_line = self._find_main_block_line(tree) + if main_line is None: + return source + + return "".join(source.splitlines(keepends=True)[: main_line - 1]) def generate_repo(self, folder_path: str): """Generates a git repository @@ -244,7 +251,7 @@ def generate_repo(self, folder_path: str): with open(os.path.join(folder_path, "tests", "__init__.py"), "w") as file: file.write("\n") - def parse_raw_code(self, raw_code: str, is_solution: bool) -> List[str]: + def parse_raw_code(self, raw_code: str, is_solution: bool) -> list[str]: """Parses the raw code returned by leetcode Args: @@ -255,34 +262,89 @@ def parse_raw_code(self, raw_code: str, is_solution: bool) -> List[str]: List[str]: a list of lines of code """ lines = [] - for _, line in enumerate(raw_code.split("\n")): + pass_after_lines = self._template_function_lines(raw_code) if not is_solution else set() + for line_number, line in enumerate(raw_code.splitlines(), start=1): lines.append(line + "\n") - if re.match(r"^\s+def\s+(.*?)\(self,", line) and not is_solution: + if line_number in pass_after_lines: lines.append(" pass\n") + if raw_code.endswith("\n"): + lines.append("") + + return lines + + def _get_template_callables(self, source: str) -> list[str]: + """Return callable names from a Python LeetCode template.""" + functions: list[str] = [] + class_stack: list[tuple[int, str]] = [] + tokens = tokenize.generate_tokens(io.StringIO(source).readline) + for token in tokens: + if token.type == tokenize.NAME and token.string == "class": + class_token = next(tokens, None) + if class_token and class_token.type == tokenize.NAME: + class_stack.append((token.start[1], class_token.string)) + elif token.type == tokenize.NAME and token.string == "def": + function_token = next(tokens, None) + if not function_token or function_token.type != tokenize.NAME: + continue + if function_token.string == "__init__" and class_stack: + functions.append(class_stack[-1][1]) + else: + functions.append(function_token.string) + + return functions + def _template_function_lines(self, source: str) -> set[int]: + """Return template function line numbers that need placeholder bodies.""" + lines: set[int] = set() + tokens = tokenize.generate_tokens(io.StringIO(source).readline) + for token in tokens: + if token.type != tokenize.NAME or token.string != "def": + continue + function_token = next(tokens, None) + if function_token and function_token.type == tokenize.NAME: + lines.add(token.start[0]) return lines - def run_black_and_isort(self, file_path: str) -> None: - """Run black and isort in a file + def _find_main_block_line(self, tree: ast.Module) -> int | None: + """Return the line number of a generated __main__ block, if present.""" + for node in tree.body: + if not isinstance(node, ast.If): + continue + compare = node.test + if not isinstance(compare, ast.Compare): + continue + if not isinstance(compare.left, ast.Name) or compare.left.id != "__name__": + continue + if len(compare.ops) != 1 or not isinstance(compare.ops[0], ast.Eq): + continue + if len(compare.comparators) != 1: + continue + comparator = compare.comparators[0] + if isinstance(comparator, ast.Constant) and comparator.value == "__main__": + return node.lineno + return None + + def run_formatter(self, file_path: str) -> None: + """Run Ruff formatter if it is available. Args: file_path (str): the path to the file """ + if not shutil.which("ruff"): + click.secho(f"Skipping formatting for {file_path}: ruff is not installed.", fg="yellow") + return try: subprocess.run( - f"isort --profile=black {file_path}", + ["ruff", "check", "--fix", "--select", "I,UP,F401", file_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - shell=True, check=True, ) subprocess.run( - f"black {file_path}", + ["ruff", "format", file_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - shell=True, check=True, ) except subprocess.CalledProcessError as e: - click.secho(e.args, fg="red") - click.secho(traceback.format_exc()) + click.secho(f"Could not format {file_path}: {e}", fg="red") diff --git a/src/leet2git/question_db.py b/src/leet2git/question_db.py index 7f0fdb9..713482c 100644 --- a/src/leet2git/question_db.py +++ b/src/leet2git/question_db.py @@ -3,17 +3,39 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import operator import os import pickle -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +import click +from pydantic import BaseModel, ConfigDict, Field + +from leet2git.config_manager import AppConfig + +DB_DIR_NAME = ".leet2git" +DB_FILE_NAME = "database.json" +LEGACY_QUESTION_DB_FILE = ".question_data.pkl" +LEGACY_ID_TITLE_MAP_FILE = ".id_title_map.pkl" +DB_VERSION = 1 + + +class TopicTag(BaseModel): + """LeetCode topic metadata attached to a question.""" -@dataclass -class QuestionData: + model_config = ConfigDict(populate_by_name=True, validate_assignment=True) + + name: str = "" + slug: str = "" + translated_name: str | None = Field(default=None, alias="translatedName") + typename: str | None = Field(default=None, alias="__typename") + + +class QuestionData(BaseModel): """Stores all the data related to a question""" + model_config = ConfigDict(validate_assignment=True) + title: str = "" title_slug: str = "" url: str = "" @@ -22,51 +44,103 @@ class QuestionData: creation_time: float = 0.0 difficulty: str = "" file_path: str = "" - test_file_path = "" + test_file_path: str = "" question_template: str = "" raw_code: str = "" language: str = "" - function_name: List[str] = field(default_factory=list) - description: List[str] = field(default_factory=list) - inputs: List[str] = field(default_factory=list) - outputs: List[str] = field(default_factory=list) - categories: List[Dict[str, str]] = field(default_factory=list) + function_name: list[str] = Field(default_factory=list) + description: list[str] = Field(default_factory=list) + inputs: list[str] = Field(default_factory=list) + outputs: list[str] = Field(default_factory=list) + categories: list[TopicTag] = Field(default_factory=list) + + def __setstate__(self, state: dict[str, object]) -> None: + """Support unpickling state written by the former dataclass model.""" + if "__dict__" in state: + super().__setstate__(state) + return + + model = type(self).model_validate(state) + object.__setattr__(self, "__dict__", model.__dict__) + object.__setattr__(self, "__pydantic_fields_set__", set(state)) + object.__setattr__(self, "__pydantic_extra__", None) + object.__setattr__(self, "__pydantic_private__", None) + + +class IdTitleMap(BaseModel): + """Maps Ids and title slugs""" + model_config = ConfigDict(validate_assignment=True) + + id_to_title: dict[int, str] = Field(default_factory=dict) + title_to_id: dict[str, int] = Field(default_factory=dict) + + def __setstate__(self, state: dict[str, object]) -> None: + """Support unpickling state written by the former dataclass model.""" + if "__dict__" in state: + super().__setstate__(state) + return + + model = type(self).model_validate(state) + object.__setattr__(self, "__dict__", model.__dict__) + object.__setattr__(self, "__pydantic_fields_set__", set(state)) + object.__setattr__(self, "__pydantic_extra__", None) + object.__setattr__(self, "__pydantic_private__", None) -@dataclass -class IdTitleMap: - """Maps Ids and title slugs""" - id_to_title: Dict[int, str] = field(default_factory=dict) - title_to_id: Dict[str, int] = field(default_factory=dict) +class DatabaseState(BaseModel): + """Versioned persisted question database.""" + + model_config = ConfigDict(validate_assignment=True) + + version: int = DB_VERSION + questions: dict[int, QuestionData] = Field(default_factory=dict) + id_title_map: IdTitleMap = Field(default_factory=IdTitleMap) class QuestionDB: """Handles the question data""" - def __init__(self, config: Dict[str, Any]): - self.db_file = os.path.join(config["data_path"], ".question_data.pkl") - self.id_title_map_file = os.path.join(config["data_path"], ".id_title_map.pkl") - self.question_data_dict: Dict[int, QuestionData] = {} + def __init__(self, config: AppConfig): + self.db_dir = os.path.join(config.source_path, DB_DIR_NAME) + self.db_file = os.path.join(self.db_dir, DB_FILE_NAME) + self.legacy_db_file = os.path.join(config.legacy_data_path, LEGACY_QUESTION_DB_FILE) + self.legacy_id_title_map_file = os.path.join( + config.legacy_data_path, + LEGACY_ID_TITLE_MAP_FILE, + ) + self.question_data_dict: dict[int, QuestionData] = {} self.id_title_map: IdTitleMap = IdTitleMap() + self.migrated_from_legacy = False def load(self): """Load the question data from disk""" if os.path.isfile(self.db_file): - with open(self.db_file, "rb") as f: - self.question_data_dict = pickle.load(f) - if os.path.isfile(self.id_title_map_file): - with open(self.id_title_map_file, "rb") as f: - self.id_title_map = pickle.load(f) + with open(self.db_file, encoding="UTF8") as f: + self._load_state(DatabaseState.model_validate_json(f.read())) + return + + if os.path.isfile(self.legacy_db_file) or os.path.isfile(self.legacy_id_title_map_file): + self._load_legacy_pickles() + self.migrated_from_legacy = True + self.save() + click.secho( + f"Migrated legacy question database into {self.db_file}.", + fg="yellow", + ) def save(self): """Save the question data to disk""" - with open(self.db_file, "wb") as f: - pickle.dump(self.question_data_dict, f) - with open(self.id_title_map_file, "wb") as f: - pickle.dump(self.id_title_map, f) - - def get_data(self) -> Dict[int, QuestionData]: + os.makedirs(self.db_dir, exist_ok=True) + state = DatabaseState( + version=DB_VERSION, + questions=self.question_data_dict, + id_title_map=self.id_title_map, + ) + with open(self.db_file, "w", encoding="UTF8") as f: + f.write(state.model_dump_json(indent=2, by_alias=True)) + + def get_data(self) -> dict[int, QuestionData]: """Returns the question data Returns: @@ -74,7 +148,7 @@ def get_data(self) -> Dict[int, QuestionData]: """ return self.question_data_dict - def get_question(self, question_id: int) -> Optional[QuestionData]: + def get_question(self, question_id: int) -> QuestionData | None: """get a question data if it exists Args: @@ -104,7 +178,7 @@ def delete_question(self, question_id: int): if question_id in self.question_data_dict: self.question_data_dict.pop(question_id) - def get_sorted_list(self, sort_by: str) -> List[QuestionData]: + def get_sorted_list(self, sort_by: str) -> list[QuestionData]: """Returns a sorted list with all the questions Args: @@ -186,6 +260,42 @@ def set_id_title_map(self, id_title_map: IdTitleMap): def reset(self): """Delete database""" - self.question_data_dict: Dict[int, QuestionData] = {} + self.question_data_dict: dict[int, QuestionData] = {} self.id_title_map: IdTitleMap = IdTitleMap() self.save() + + def _load_question_data(self, raw_data: object) -> dict[int, QuestionData]: + """Normalize legacy pickle payloads into Pydantic models.""" + if not isinstance(raw_data, dict): + return {} + + loaded_data: dict[int, QuestionData] = {} + for question_id, question_data in raw_data.items(): + if not isinstance(question_id, int | str): + continue + loaded_data[int(question_id)] = ( + question_data + if isinstance(question_data, QuestionData) + else QuestionData.model_validate(question_data) + ) + return loaded_data + + def _load_id_title_map(self, raw_data: object) -> IdTitleMap: + """Normalize legacy pickle payloads into a Pydantic id/title map.""" + if isinstance(raw_data, IdTitleMap): + return raw_data + return IdTitleMap.model_validate(raw_data) + + def _load_state(self, state: DatabaseState) -> None: + """Copy a versioned database state into this instance.""" + self.question_data_dict = state.questions + self.id_title_map = state.id_title_map + + def _load_legacy_pickles(self) -> None: + """Load legacy platform-path pickle files for one-way migration.""" + if os.path.isfile(self.legacy_db_file): + with open(self.legacy_db_file, "rb") as f: + self.question_data_dict = self._load_question_data(pickle.load(f)) + if os.path.isfile(self.legacy_id_title_map_file): + with open(self.legacy_id_title_map_file, "rb") as f: + self.id_title_map = self._load_id_title_map(pickle.load(f)) diff --git a/src/leet2git/readme_handler.py b/src/leet2git/readme_handler.py index 7864e86..6bc2b58 100644 --- a/src/leet2git/readme_handler.py +++ b/src/leet2git/readme_handler.py @@ -3,10 +3,11 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + import os from dataclasses import dataclass, field -from typing import Any, Dict, List +from leet2git.config_manager import AppConfig from leet2git.question_db import QuestionData, QuestionDB @@ -15,27 +16,27 @@ class ReadmeTable: """Data related to a table""" title: str = "" - fields: List[str] = field(default_factory=list) - values: List[List[str]] = field(default_factory=list) + fields: list[str] = field(default_factory=list) + values: list[list[str]] = field(default_factory=list) class ReadmeHandler: """Updates the README with the solved questions""" - def __init__(self, config: Dict[str, Any]): - self.readme_file: str = os.path.join(config["source_path"], "README.md") - self.print_categories: bool = config["readme"]["show_category"] - self.print_difficulty: bool = config["readme"]["show_difficulty"] + def __init__(self, config: AppConfig): + self.readme_file: str = os.path.join(config.source_path, "README.md") + self.print_categories: bool = config.readme.show_category + self.print_difficulty: bool = config.readme.show_difficulty - def build_readme(self, question_list: List[QuestionData]): + def build_readme(self, question_list: list[QuestionData]): """Updates the README file Args: question_list (List[QuestionData]): a sorted list with the question data """ - category_tables: Dict[str, ReadmeTable] = {} - difficulty_tables: Dict[str, ReadmeTable] = { + category_tables: dict[str, ReadmeTable] = {} + difficulty_tables: dict[str, ReadmeTable] = { "Easy": ReadmeTable( title='Easy Questions', fields=["ID", "Problem", "Leetcode ID", "Categories"], @@ -62,22 +63,22 @@ def build_readme(self, question_list: List[QuestionData]): categories_str = "" for c in question.categories: if self.print_categories: - categories_str += f"[{c['name']}](#{c['slug']}), " - if c["slug"] not in category_tables: - category_tables[c["slug"]] = ReadmeTable( - title=f"""{c['name']}""", + categories_str += f"[{c.name}](#{c.slug}), " + if c.slug not in category_tables: + category_tables[c.slug] = ReadmeTable( + title=f"""{c.name}""", fields=["ID", "Problem", "Leetcode ID", "Difficulty"], ) - category_tables[c["slug"]].values.append( + category_tables[c.slug].values.append( [ - str(len(category_tables[c["slug"]].values) + 1), + str(len(category_tables[c.slug].values) + 1), f"[{question.title}]({question.file_path})", f"[{question.id}]({question.url})", difficulty_str, ] ) else: - categories_str += c["name"] + ", " + categories_str += c.name + ", " categories_str = categories_str[:-2] if not question.difficulty: @@ -105,8 +106,8 @@ def build_readme(self, question_list: List[QuestionData]): def dump_tables( self, main_table: ReadmeTable, - category_tables: Dict[str, ReadmeTable], - difficulty_tables: Dict[str, ReadmeTable], + category_tables: dict[str, ReadmeTable], + difficulty_tables: dict[str, ReadmeTable], ): """Generates the README file @@ -116,7 +117,6 @@ def dump_tables( difficulty_tables (Dict[str, ReadmeTable]): a dictionary with tables separated by difficulty """ with open(self.readme_file, "w", encoding="UTF8") as f: - f.write("# Table of Contents\n") f.write(f"[{main_table.title}](#summary) \n") if self.print_difficulty: diff --git a/src/leet2git/version.py b/src/leet2git/version.py index e56af19..bd08a69 100755 --- a/src/leet2git/version.py +++ b/src/leet2git/version.py @@ -3,12 +3,31 @@ Authors: - Yuri Rocha (yurirocha15@gmail.com) """ + +import ast import os import platform -import re import sys -__version__ = "0.2.1" +from pydantic import BaseModel, ValidationError, field_validator + +__version__ = "0.3.0" + + +class VersionString(BaseModel): + """Validated semantic version string.""" + + value: str + + @field_validator("value") + @classmethod + def require_three_numeric_parts(cls, value: str) -> str: + """Validate a simple MAJOR.MINOR.PATCH version.""" + version = value.removeprefix("v") + parts = version.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + raise ValueError("version must use MAJOR.MINOR.PATCH") + return version def version_info() -> str: @@ -18,7 +37,7 @@ def version_info() -> str: "python version": sys.version.replace("\n", " "), "platform": platform.platform(), } - return "\n".join(f"{k + ':' :>30} {v}" for k, v in info.items()) + return "\n".join(f"{k + ':':>30} {v}" for k, v in info.items()) def update_version_string(new_version: str): @@ -27,28 +46,39 @@ def update_version_string(new_version: str): Args: new_version (str): the new version """ - # remove trailing v - if new_version and new_version[0] == "v": - new_version = new_version[1:] - - if not re.match(r"^(\d+\.)?(\d+\.)?(\d+)$", new_version): + try: + version = VersionString(value=new_version).value + except ValidationError: print(f"Version {new_version} is not valid") return file_path = os.path.abspath(__file__) - version_regex = re.compile(r"(^_*?version_*?\s*=\s*['\"])(\d+\.\d+\.\d+)", re.M) with open(file_path, "r+") as f: content = f.read() + version_line = _find_version_line(content) + if version_line is None: + print("Could not find __version__") + return + + lines = content.splitlines(keepends=True) + line_ending = "\n" if lines[version_line - 1].endswith("\n") else "" + lines[version_line - 1] = f'__version__ = "{version}"{line_ending}' f.seek(0) - f.write( - re.sub( - version_regex, - lambda match: f"{match.group(1)}{new_version}", - content, - ) - ) + f.write("".join(lines)) f.truncate() +def _find_version_line(content: str) -> int | None: + """Return the line where __version__ is assigned.""" + tree = ast.parse(content) + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__version__": + return node.lineno + return None + + if __name__ == "__main__": update_version_string(sys.argv[1]) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c6d9404 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,547 @@ +from click.testing import CliRunner + +from leet2git.config_manager import AppConfig +from leet2git.leet2git import leet2git +from leet2git.leetcode_client import LeetcodeAPIError, LeetcodeAuthError +from leet2git.leetcode_models import SubmissionListResponse +from leet2git.question_db import IdTitleMap, QuestionData + + +class FakeConfigManager: + def __init__(self): + self.config = AppConfig(language="python3", source_path="") + + def load_config(self, override_config=None): + if override_config and override_config.source_path: + self.config.source_path = override_config.source_path + if override_config and override_config.language: + self.config.language = override_config.language + + +class EmptyQuestionDB: + def __init__(self, config): + self.config = config + + def load(self): + pass + + def check_if_exists(self, question_id): + return False + + def check_if_slug_is_known(self, question_id): + return False + + +def test_get_reports_auth_error_without_traceback(monkeypatch): + class AuthFailClient: + def __init__(self): + raise LeetcodeAuthError("auth failed") + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", FakeConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", EmptyQuestionDB) + monkeypatch.setattr("leet2git.leet2git.LeetcodeClient", AuthFailClient) + + result = CliRunner().invoke(leet2git, ["get", "1"]) + + assert result.exit_code == 0 + assert "auth failed" in result.output + assert "Traceback" not in result.output + + +def test_get_skips_existing_question(monkeypatch): + class ExistingQuestionDB(EmptyQuestionDB): + def check_if_exists(self, question_id): + return True + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", FakeConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", ExistingQuestionDB) + + result = CliRunner().invoke(leet2git, ["get", "1"]) + + assert result.exit_code == 0 + assert "Question already imported" in result.output + + +def test_get_imports_question_and_updates_readme(monkeypatch, tmp_path): + class ConfigManagerWithSource(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path)) + + class ImportOneQuestionDB(EmptyQuestionDB): + instances = [] + + def __init__(self, config): + super().__init__(config) + self.questions = {} + self.id_title_map = IdTitleMap() + self.save_count = 0 + self.instances.append(self) + + def check_if_exists(self, question_id): + return question_id in self.questions + + def check_if_slug_is_known(self, question_id): + return question_id in self.id_title_map.id_to_title + + def set_id_title_map(self, id_title_map): + self.id_title_map = id_title_map + + def get_title_from_id(self, question_id): + return self.id_title_map.id_to_title[question_id] + + def add_question(self, question): + self.questions[question.id] = question + + def save(self): + self.save_count += 1 + + def get_sorted_list(self, sort_by): + return sorted(self.questions.values(), key=lambda question: question.creation_time) + + class FakeClient: + def get_id_title_map(self): + return IdTitleMap(id_to_title={1: "two-sum"}, title_to_id={"two-sum": 1}) + + class FakeReadmeHandler: + built_lists = [] + + def __init__(self, config): + self.config = config + + def build_readme(self, question_list): + self.built_lists.append(question_list) + + def fake_generate_files(args, question_id, title_slug, lc, timestamp, config, code=""): + args[question_id] = QuestionData( + id=question_id, + title="Two Sum", + title_slug=title_slug, + creation_time=timestamp, + ) + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", ConfigManagerWithSource) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", ImportOneQuestionDB) + monkeypatch.setattr("leet2git.leet2git.LeetcodeClient", FakeClient) + monkeypatch.setattr("leet2git.leet2git.ReadmeHandler", FakeReadmeHandler) + monkeypatch.setattr("leet2git.leet2git.generate_files", fake_generate_files) + + result = CliRunner().invoke(leet2git, ["get", "1"]) + + imported_db = ImportOneQuestionDB.instances[-1] + assert result.exit_code == 0 + assert imported_db.questions[1].title == "Two Sum" + assert imported_db.save_count == 2 + assert [question.id for question in FakeReadmeHandler.built_lists[-1]] == [1] + + +def test_submit_reports_api_error_without_traceback(monkeypatch): + class SubmitQuestionDB(EmptyQuestionDB): + def get_question(self, question_id): + return QuestionData(id=question_id, internal_id=1, title_slug="two-sum") + + class FakeHandler: + def generate_submission_file(self): + return "class Solution: ..." + + class APIFailClient: + def submit_question(self, *args, **kwargs): + raise LeetcodeAPIError("api failed") + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", FakeConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", SubmitQuestionDB) + monkeypatch.setattr("leet2git.leet2git.create_file_handler", lambda *_: FakeHandler()) + monkeypatch.setattr("leet2git.leet2git.LeetcodeClient", APIFailClient) + + result = CliRunner().invoke(leet2git, ["submit", "1"]) + + assert result.exit_code == 0 + assert "api failed" in result.output + assert "Traceback" not in result.output + + +def test_submit_reports_missing_question(monkeypatch): + class SubmitQuestionDB(EmptyQuestionDB): + def get_question(self, question_id): + return None + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", FakeConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", SubmitQuestionDB) + + result = CliRunner().invoke(leet2git, ["submit", "1"]) + + assert result.exit_code == 0 + assert "Could not find the question with id 1" in result.output + + +def test_delete_removes_files_from_configured_source_path(monkeypatch, tmp_path): + source_file = tmp_path / "src" / "leetcode_1_two_sum.py" + source_file.parent.mkdir() + source_file.write_text("class Solution: ...\n", encoding="UTF8") + + class ConfigManagerWithSource(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path)) + + class DeleteQuestionDB(EmptyQuestionDB): + def __init__(self, config): + super().__init__(config) + self.deleted = False + + def check_if_exists(self, question_id): + return True + + def get_data(self): + return { + 1: QuestionData( + id=1, + file_path="src/leetcode_1_two_sum.py", + test_file_path="", + ) + } + + def delete_question(self, question_id): + self.deleted = True + + def save(self): + pass + + def get_sorted_list(self, sort_by): + return [] + + class FakeReadmeHandler: + def __init__(self, config): + self.config = config + + def build_readme(self, question_list): + pass + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", ConfigManagerWithSource) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", DeleteQuestionDB) + monkeypatch.setattr("leet2git.leet2git.ReadmeHandler", FakeReadmeHandler) + + result = CliRunner().invoke(leet2git, ["delete", "1"]) + + assert result.exit_code == 0 + assert not source_file.exists() + assert "removed" in result.output + + +def test_delete_reports_missing_question(monkeypatch): + monkeypatch.setattr("leet2git.leet2git.ConfigManager", FakeConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", EmptyQuestionDB) + + result = CliRunner().invoke(leet2git, ["delete", "1"]) + + assert result.exit_code == 0 + assert "could not be found" in result.output + + +def test_run_submits_generated_code_with_question_inputs(monkeypatch, tmp_path): + class ConfigManagerWithSource(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path)) + + class RunQuestionDB(EmptyQuestionDB): + def get_question(self, question_id): + return QuestionData( + id=question_id, + internal_id=1, + inputs=["[2,7,11,15], 9", "[3,2,4], 6"], + ) + + def get_title_from_id(self, question_id): + return "two-sum" + + class FakeHandler: + def generate_submission_file(self): + return "class Solution: ..." + + class FakeClient: + calls = [] + + def submit_question(self, *args): + self.calls.append(args) + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", ConfigManagerWithSource) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", RunQuestionDB) + monkeypatch.setattr("leet2git.leet2git.create_file_handler", lambda *_: FakeHandler()) + monkeypatch.setattr("leet2git.leet2git.LeetcodeClient", FakeClient) + + result = CliRunner().invoke(leet2git, ["run", "1"]) + + assert result.exit_code == 0 + assert FakeClient.calls == [ + ( + "class Solution: ...", + 1, + "two-sum", + "python3", + True, + "[2,7,11,15]\n9\n[3,2,4]\n6", + ) + ] + + +def test_run_reports_missing_question(monkeypatch): + class RunQuestionDB(EmptyQuestionDB): + def get_question(self, question_id): + return None + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", FakeConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", RunQuestionDB) + + result = CliRunner().invoke(leet2git, ["run", "1"]) + + assert result.exit_code == 0 + assert "Could not find the question with id 1" in result.output + + +def test_import_all_filters_pages_and_updates_readme(monkeypatch, tmp_path): + class ConfigManagerWithSource(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path)) + + class ImportQuestionDB(EmptyQuestionDB): + instances = [] + + def __init__(self, config): + super().__init__(config) + self.questions = {} + self.save_count = 0 + self.instances.append(self) + + def add_question(self, question): + self.questions[question.id] = question + + def check_if_exists(self, question_id): + return question_id in self.questions + + def save(self): + self.save_count += 1 + + def get_sorted_list(self, sort_by): + return sorted(self.questions.values(), key=lambda question: question.id) + + class FakeClient: + def __init__(self): + self.pages = [ + SubmissionListResponse.model_validate( + { + "submissions_dump": [ + { + "title_slug": "two-sum", + "status_display": "Accepted", + "lang": "python3", + "timestamp": 10, + "code": "code one", + }, + { + "title_slug": "three-sum", + "status_display": "Accepted", + "lang": "java", + "timestamp": 20, + "code": "code skipped", + }, + ], + "has_next": True, + "last_key": "next-page", + } + ), + SubmissionListResponse.model_validate( + { + "submissions_dump": [ + { + "title_slug": "add-two-numbers", + "status_display": "Accepted", + "lang": "python3", + "timestamp": 30, + "code": "code two", + }, + { + "title_slug": "median-of-two-sorted-arrays", + "status_display": "Wrong Answer", + "lang": "python3", + "timestamp": 40, + "code": "code skipped", + }, + ], + "has_next": False, + "last_key": "", + } + ), + ] + + def get_submission_list(self, last_key="", offset=0): + assert (last_key, offset) in {("", 0), ("next-page", 20)} + return self.pages[offset // 20] + + class FakeProcess: + def __init__(self, target, args): + self.target = target + self.args = args + + def start(self): + self.target(*self.args) + + def join(self): + pass + + class FakeSyncManager: + def start(self, initializer=None): + if initializer: + initializer() + + def dict(self): + return {} + + def shutdown(self): + pass + + class FakeReadmeHandler: + built_lists = [] + + def __init__(self, config): + self.config = config + + def build_readme(self, question_list): + self.built_lists.append(question_list) + + def fake_generate_files(ret_dict, qid, title_slug, lc, timestamp, config, code=""): + ret_dict[qid] = QuestionData( + id=qid, + title=title_slug, + title_slug=title_slug, + creation_time=timestamp, + raw_code=code, + ) + + title_ids = { + "two-sum": 1, + "three-sum": 15, + "add-two-numbers": 2, + "median-of-two-sorted-arrays": 4, + } + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", ConfigManagerWithSource) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", ImportQuestionDB) + monkeypatch.setattr("leet2git.leet2git.LeetcodeClient", FakeClient) + monkeypatch.setattr("leet2git.leet2git.Process", FakeProcess) + monkeypatch.setattr("leet2git.leet2git.SyncManager", FakeSyncManager) + monkeypatch.setattr("leet2git.leet2git.ReadmeHandler", FakeReadmeHandler) + monkeypatch.setattr("leet2git.leet2git.generate_files", fake_generate_files) + monkeypatch.setattr( + "leet2git.leet2git.get_question_id", + lambda title_slug, qdb, lc: title_ids[title_slug], + ) + monkeypatch.setattr("leet2git.leet2git.time.sleep", lambda _: None) + + result = CliRunner().invoke(leet2git, ["import-all"]) + + imported_db = ImportQuestionDB.instances[-1] + assert result.exit_code == 0 + assert "In total, 2 questions were imported!" in result.output + assert sorted(imported_db.questions) == [1, 2] + assert imported_db.questions[1].raw_code == "code one" + assert imported_db.questions[2].raw_code == "code two" + assert imported_db.save_count == 3 + assert [question.id for question in FakeReadmeHandler.built_lists[-1]] == [1, 2] + + +def test_init_can_create_repository(monkeypatch, tmp_path): + class InitConfigManager(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path / "initial")) + + def reset_config(self, source_repository, language): + self.config.source_path = source_repository + self.config.language = language + + class FakeHandler: + generated_repos = [] + + def generate_repo(self, source_path): + self.generated_repos.append(source_path) + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", InitConfigManager) + monkeypatch.setattr("leet2git.leet2git.create_file_handler", lambda *_: FakeHandler()) + + result = CliRunner().invoke( + leet2git, + ["init", "--source-repository", str(tmp_path / "repo"), "--language", "rust", "--create-repo"], + ) + + assert result.exit_code == 0 + assert FakeHandler.generated_repos == [str(tmp_path / "repo")] + + +def test_reset_soft_resets_database_after_confirmation(monkeypatch, tmp_path): + class ResetConfigManager(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path)) + + def reset_config(self, source_repository, language): + self.config.source_path = source_repository + self.config.language = language + + class ResetQuestionDB(EmptyQuestionDB): + reset_count = 0 + + def reset(self): + self.__class__.reset_count += 1 + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", ResetConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", ResetQuestionDB) + + result = CliRunner().invoke( + leet2git, + ["reset", "--soft", "--source-repository", str(tmp_path), "--language", "python3"], + input="y\n", + ) + + assert result.exit_code == 0 + assert ResetQuestionDB.reset_count == 1 + + +def test_reset_hard_removes_generated_files_and_regenerates_repo(monkeypatch, tmp_path): + source_file = tmp_path / "src" / "leetcode_1_two_sum.py" + test_file = tmp_path / "tests" / "test_1.py" + source_file.parent.mkdir() + test_file.parent.mkdir() + source_file.write_text("solution\n", encoding="UTF8") + test_file.write_text("test\n", encoding="UTF8") + + class ResetConfigManager(FakeConfigManager): + def __init__(self): + self.config = AppConfig(language="python3", source_path=str(tmp_path)) + + def reset_config(self, source_repository, language): + self.config.source_path = source_repository + self.config.language = language + + class ResetQuestionDB(EmptyQuestionDB): + reset_count = 0 + + def reset(self): + self.__class__.reset_count += 1 + + class FakeHandler: + generated_repos = [] + + def generate_repo(self, source_path): + self.generated_repos.append(source_path) + + monkeypatch.setattr("leet2git.leet2git.ConfigManager", ResetConfigManager) + monkeypatch.setattr("leet2git.leet2git.QuestionDB", ResetQuestionDB) + monkeypatch.setattr("leet2git.leet2git.create_file_handler", lambda *_: FakeHandler()) + + result = CliRunner().invoke( + leet2git, + ["reset", "--hard", "--source-repository", str(tmp_path), "--language", "python3"], + input="y\n", + ) + + assert result.exit_code == 0 + assert not source_file.exists() + assert not test_file.exists() + assert ResetQuestionDB.reset_count == 1 + assert FakeHandler.generated_repos == [str(tmp_path)] diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..8f8a6cd --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,81 @@ +import json + +from leet2git.config_manager import AppConfig, ConfigManager, ConfigOverrides + + +def make_config_manager(tmp_path): + manager = ConfigManager.__new__(ConfigManager) + manager._config_path = str(tmp_path) + manager._legacy_data_path = str(tmp_path / "data") + manager._config_file = str(tmp_path / "config.json") + manager._config = None + return manager + + +def test_app_config_defaults_are_validated(): + config = AppConfig(source_path="/tmp/solutions") + + assert config.language == "python3" + assert config.source_path == "/tmp/solutions" + assert config.readme.show_difficulty is True + assert config.source_code.add_description is True + assert config.test_code.generate_tests is True + + +def test_initial_config_creation_does_not_open_editor(tmp_path, monkeypatch): + def fail_edit(**kwargs): + raise AssertionError("click.edit should not be called") + + class FakePlatformDirs: + user_config_dir = str(tmp_path / "config") + user_data_dir = str(tmp_path / "data") + + def __init__(self, appname, appauthor): + self.appname = appname + self.appauthor = appauthor + + monkeypatch.setattr("leet2git.config_manager.PlatformDirs", FakePlatformDirs) + monkeypatch.setattr("leet2git.config_manager.click.edit", fail_edit) + + manager = ConfigManager() + + assert manager.config.source_path == "" + assert (tmp_path / "config" / "config.json").exists() + + +def test_load_config_validates_json_and_applies_overrides(tmp_path): + manager = make_config_manager(tmp_path) + with open(manager._config_file, "w", encoding="UTF8") as file: + json.dump( + { + "language": "python3", + "source_path": "/tmp/old", + "readme": {"show_difficulty": False, "show_category": True}, + "source_code": {"add_description": False}, + "test_code": {"generate_tests": True}, + }, + file, + ) + + manager.load_config(ConfigOverrides(language="rust", source_path="/tmp/new")) + + assert manager.config.language == "rust" + assert manager.config.source_path == "/tmp/new" + assert manager.config.legacy_data_path == str(tmp_path / "data") + assert manager.config.readme.show_difficulty is False + assert manager.config.source_code.add_description is False + + +def test_reset_config_writes_user_editable_json_without_data_path(tmp_path, monkeypatch): + manager = make_config_manager(tmp_path) + monkeypatch.setattr("leet2git.config_manager.click.edit", lambda **_: None) + + manager.reset_config("/tmp/solutions", "python3") + + with open(manager._config_file, encoding="UTF8") as file: + saved_config = json.load(file) + + assert saved_config["source_path"] == "/tmp/solutions" + assert saved_config["language"] == "python3" + assert "legacy_data_path" not in saved_config + assert manager.config.legacy_data_path == str(tmp_path / "data") diff --git a/tests/test_default_handler.py b/tests/test_default_handler.py new file mode 100644 index 0000000..fe11996 --- /dev/null +++ b/tests/test_default_handler.py @@ -0,0 +1,47 @@ +from leet2git.config_manager import AppConfig +from leet2git.default_handler import DefaultHandler +from leet2git.question_db import QuestionData + + +def test_default_handler_generates_repository_scaffold(tmp_path): + handler = DefaultHandler() + + handler.generate_repo(str(tmp_path)) + + assert (tmp_path / ".git").exists() + assert (tmp_path / "README.md").read_text(encoding="UTF8") == "" + assert (tmp_path / "src").is_dir() + + +def test_default_handler_generates_source_in_parent_directories(tmp_path): + question = QuestionData( + id=2, + title="Rust Problem", + difficulty="Medium", + url="https://leetcode.com/problems/rust-problem", + file_path="src/leetcode_2_rust_problem", + language="rust", + question_template="impl Solution {}\n", + ) + handler = DefaultHandler() + handler.set_data(question, AppConfig(language="rust", source_path=str(tmp_path))) + + file_path = handler.generate_source() + + generated = tmp_path / file_path + assert generated.exists() + assert "impl Solution {}" in generated.read_text(encoding="UTF8") + + +def test_default_handler_reads_submission_file(tmp_path): + question = QuestionData( + file_path="src/leetcode_2_rust_problem.rs", + language="rust", + ) + generated = tmp_path / question.file_path + generated.parent.mkdir() + generated.write_text("impl Solution {}\n", encoding="UTF8") + handler = DefaultHandler() + handler.set_data(question, AppConfig(language="rust", source_path=str(tmp_path))) + + assert handler.generate_submission_file() == "impl Solution {}\n" diff --git a/tests/test_file_handler.py b/tests/test_file_handler.py new file mode 100644 index 0000000..9d2e2a4 --- /dev/null +++ b/tests/test_file_handler.py @@ -0,0 +1,55 @@ +from leet2git.config_manager import AppConfig +from leet2git.file_handler import generate_files +from leet2git.leetcode_client import LeetcodeAPIError +from leet2git.question_db import QuestionData + + +def test_generate_files_builds_source_and_tests(monkeypatch): + class FakeClient: + def get_question_data(self, qid, title_slug, language, code): + return ( + QuestionData( + id=qid, + title="Two Sum", + title_slug=title_slug, + file_path="src/leetcode_1_two_sum", + language=language, + inputs=["[2,7,11,15], 9"], + outputs=["[0,1]"], + raw_code=code, + ), + True, + ) + + class FakeHandler: + def get_function_name(self): + return ["twoSum"] + + def generate_source(self): + return "src/leetcode_1_two_sum.py" + + def generate_tests(self): + return "tests/test_1.py" + + monkeypatch.setattr("leet2git.file_handler.create_file_handler", lambda *_: FakeHandler()) + + generated = {} + generate_files(generated, 1, "two-sum", FakeClient(), 123.0, AppConfig(), "solution code") + + assert generated[1].creation_time == 123.0 + assert generated[1].file_path == "src/leetcode_1_two_sum.py" + assert generated[1].test_file_path == "tests/test_1.py" + assert generated[1].function_name == ["twoSum"] + assert generated[1].raw_code == "solution code" + + +def test_generate_files_reports_leetcode_errors(capsys): + class FakeClient: + def get_question_data(self, *args): + raise LeetcodeAPIError("api changed") + + generated = {} + generate_files(generated, 1, "two-sum", FakeClient(), 123.0, AppConfig()) + + assert generated == {} + assert "api changed" in capsys.readouterr().out diff --git a/tests/test_leetcode_client.py b/tests/test_leetcode_client.py new file mode 100644 index 0000000..7817fd9 --- /dev/null +++ b/tests/test_leetcode_client.py @@ -0,0 +1,737 @@ +import asyncio +import json +from http.cookiejar import Cookie, CookieJar + +import httpx +import pytest +from pydantic import BaseModel + +from leet2git.leetcode_client import LeetcodeAPIError, LeetcodeAuthError, LeetcodeClient + + +class DummyResponse(BaseModel): + ok: bool + + +def cookie(name: str, value: str, domain: str) -> Cookie: + return Cookie( + version=0, + name=name, + value=value, + port=None, + port_specified=False, + domain=domain, + domain_specified=domain.startswith("."), + domain_initial_dot=domain.startswith("."), + path="/", + path_specified=True, + secure=True, + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False, + ) + + +def cookie_jar(*cookies: Cookie) -> CookieJar: + jar = CookieJar() + for cookie_item in cookies: + jar.set_cookie(cookie_item) + return jar + + +def make_client(handler): + client = LeetcodeClient.__new__(LeetcodeClient) + client.divider = "/" + client.cookies = "" + client.csrftoken = "csrf" + client._transport = httpx.MockTransport(handler) + client._timeout = 5.0 + return client + + +def question_response( + *, + content="

desc

", + sample_test_case="[1]\n1", + example_testcases="[1]\n1", +): + return { + "data": { + "question": { + "questionId": "1", + "title": "Two Sum", + "titleSlug": "two-sum", + "content": content, + "difficulty": "Easy", + "exampleTestcases": example_testcases, + "sampleTestCase": sample_test_case, + "topicTags": [{"name": "Array", "slug": "array"}], + "codeSnippets": [ + { + "lang": "Python3", + "langSlug": "python3", + "code": "class Solution:\n def twoSum(self):\n", + } + ], + } + } + } + + +def test_get_cookies_reads_chrome_session_and_host_only_csrf(monkeypatch): + chrome_cookies = cookie_jar( + cookie("LEETCODE_SESSION", "session", ".leetcode.com"), + cookie("csrftoken", "csrf", "leetcode.com"), + cookie("unrelated", "ignored", "example.com"), + ) + + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.chrome", lambda: chrome_cookies) + monkeypatch.setattr( + "leet2git.leetcode_client.browser_cookie3.firefox", + lambda: pytest.fail("Firefox should not be used when Chrome has a session"), + ) + + client = LeetcodeClient() + + assert client.csrftoken == "csrf" + assert "LEETCODE_SESSION=session" in client.cookies + assert "csrftoken=csrf" in client.cookies + assert "unrelated=ignored" not in client.cookies + + +def test_get_cookies_continues_after_browser_cookie_error(monkeypatch, capsys): + firefox_cookies = cookie_jar( + cookie("LEETCODE_SESSION", "firefox-session", ".leetcode.com"), + cookie("csrftoken", "firefox-csrf", "leetcode.com"), + ) + + monkeypatch.setattr( + "leet2git.leetcode_client.browser_cookie3.chrome", + lambda: (_ for _ in ()).throw( + __import__("browser_cookie3").BrowserCookieError("chrome unavailable") + ), + ) + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.firefox", lambda: firefox_cookies) + + client = LeetcodeClient() + + assert client.csrftoken == "firefox-csrf" + assert "LEETCODE_SESSION=firefox-session" in client.cookies + assert "chrome unavailable" in capsys.readouterr().out + + +def test_init_sets_windows_path_divider(monkeypatch): + chrome_cookies = cookie_jar(cookie("LEETCODE_SESSION", "session", ".leetcode.com")) + monkeypatch.setattr("leet2git.leetcode_client.platform.system", lambda: "Windows") + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.chrome", lambda: chrome_cookies) + monkeypatch.setattr( + "leet2git.leetcode_client.browser_cookie3.firefox", + lambda: pytest.fail("Firefox should not be used when Chrome has a session"), + ) + + client = LeetcodeClient() + + assert client.divider == "\\" + + +def test_get_cookies_requires_leetcode_session(monkeypatch): + chrome_cookies = cookie_jar(cookie("csrftoken", "csrf", "leetcode.com")) + firefox_cookies = cookie_jar() + + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.chrome", lambda: chrome_cookies) + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.firefox", lambda: firefox_cookies) + + with pytest.raises(LeetcodeAuthError, match="Could not find a LeetCode login session"): + LeetcodeClient() + + +def test_get_cookies_allows_missing_csrf_for_read_only_requests(monkeypatch): + chrome_cookies = cookie_jar(cookie("LEETCODE_SESSION", "session", ".leetcode.com")) + + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.chrome", lambda: chrome_cookies) + monkeypatch.setattr( + "leet2git.leetcode_client.browser_cookie3.firefox", + lambda: pytest.fail("Firefox should not be used when Chrome has a session"), + ) + + client = LeetcodeClient() + + assert client.csrftoken == "" + assert "x-csrftoken" not in client.get_headers() + + +def test_get_cookies_uses_firefox_when_chrome_has_no_session(monkeypatch): + chrome_cookies = cookie_jar(cookie("csrftoken", "stale", "leetcode.com")) + firefox_cookies = cookie_jar( + cookie("LEETCODE_SESSION", "firefox-session", ".leetcode.com"), + cookie("csrftoken", "firefox-csrf", "leetcode.com"), + ) + + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.chrome", lambda: chrome_cookies) + monkeypatch.setattr("leet2git.leetcode_client.browser_cookie3.firefox", lambda: firefox_cookies) + + client = LeetcodeClient() + + assert client.csrftoken == "firefox-csrf" + assert "LEETCODE_SESSION=firefox-session" in client.cookies + + +def test_get_headers_include_cookie_and_conditional_csrf(): + client = LeetcodeClient.__new__(LeetcodeClient) + client.cookies = "LEETCODE_SESSION=session; csrftoken=csrf" + client.csrftoken = "csrf" + + headers = client.get_headers() + + assert headers["cookie"] == "LEETCODE_SESSION=session; csrftoken=csrf" + assert headers["x-csrftoken"] == "csrf" + assert headers["user-agent"] + + +def test_get_question_data_parses_description_examples_categories_and_raw_code(): + long_line = " ".join(["word"] * 30) + content = ( + f"{long_line}\n" + "x2\n" + "Example 1:\n" + "Input: nums = [2,7,11,15], target = 9\n" + "Output: [0,1]\n" + "Example 2:\n" + "Input: nums = [3,2,4], target = 6\n" + "Output\n" + "[1,2]" + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://leetcode.com/graphql" + return httpx.Response( + 200, + json=question_response( + content=content, + sample_test_case="[2,7,11,15]\n9", + example_testcases="[2,7,11,15]\n9\n[3,2,4]\n6", + ), + ) + + client = make_client(handler) + + data, is_new = client.get_question_data(1, "two-sum", "python3", "accepted code") + + assert is_new is True + assert data.internal_id == 1 + assert data.title == "Two Sum" + assert data.url == "https://leetcode.com/problems/two-sum" + assert data.question_template.startswith("class Solution") + assert data.categories[0].slug == "array" + assert data.inputs == ["[2,7,11,15], 9", "[3,2,4], 6"] + assert data.outputs == ["[0,1]", "[1,2]"] + assert data.raw_code == "accepted code" + assert data.file_path == "src/leetcode_1_two_sum" + assert any("x^2" in line for line in data.description) + assert all(len(line) <= 100 for line in data.description if line.startswith("word")) + + +def test_mutating_requests_require_csrf(): + client = make_client(lambda _: httpx.Response(500)) + client.cookies = "LEETCODE_SESSION=session" + client.csrftoken = "" + + with pytest.raises(LeetcodeAuthError, match="csrftoken"): + asyncio.run( + client.async_submit_question( + "class Solution: ...", + 1, + "two-sum", + "python3", + is_test=True, + test_input="[2,7,11,15]\n9", + ) + ) + + +def test_async_scrap_question_data_posts_current_graphql_shape(): + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + + assert request.method == "POST" + assert str(request.url) == "https://leetcode.com/graphql" + assert payload["operationName"] == "questionData" + assert payload["variables"] == {"titleSlug": "two-sum"} + + return httpx.Response( + 200, + json={ + "data": { + "question": { + "questionId": "1", + "title": "Two Sum", + "titleSlug": "two-sum", + "content": "

desc

", + "difficulty": "Easy", + "exampleTestcases": "[1]\n1", + "sampleTestCase": "[1]\n1", + "topicTags": [], + "codeSnippets": [ + { + "langSlug": "python3", + "code": "class Solution:\n def twoSum(self):\n", + } + ], + } + } + }, + ) + + client = make_client(handler) + + response = asyncio.run(client.async_scrap_question_data("two-sum")) + + assert response.data.question + assert response.data.question.question_id == "1" + + +def test_async_get_id_title_map_parses_problem_list(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "https://leetcode.com/api/problems/all/" + return httpx.Response( + 200, + json={ + "stat_status_pairs": [ + { + "stat": { + "frontend_question_id": 1, + "question__title_slug": "two-sum", + } + } + ] + }, + ) + + client = make_client(handler) + + id_title_map = asyncio.run(client.async_get_id_title_map()) + + assert id_title_map.id_to_title == {1: "two-sum"} + assert id_title_map.title_to_id == {"two-sum": 1} + + +def test_async_get_latest_submission_uses_submission_history(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(str(request.url)) + if str(request.url) == "https://leetcode.com/api/problems/all/": + return httpx.Response( + 200, + json={ + "stat_status_pairs": [ + { + "stat": { + "frontend_question_id": 1, + "question__title_slug": "two-sum", + } + } + ] + }, + ) + return httpx.Response( + 200, + json={ + "submissions_dump": [ + { + "title_slug": "two-sum", + "status_display": "Accepted", + "lang": "python3", + "timestamp": 123, + "code": "class Solution: ...", + } + ], + "has_next": False, + "last_key": "", + }, + ) + + client = make_client(handler) + + code = asyncio.run(client.async_get_latest_submission("1", "python3")) + + assert code == "class Solution: ..." + assert requests == [ + "https://leetcode.com/api/problems/all/", + "https://leetcode.com/api/submissions/?offset=0&limit=20&lastkey=", + ] + + +def test_async_get_latest_submission_rejects_non_numeric_question_id(): + client = make_client(lambda _: httpx.Response(500)) + + with pytest.raises(LeetcodeAPIError, match='Question id "abc" is not numeric'): + asyncio.run(client.async_get_latest_submission("abc", "python3")) + + +def test_async_get_latest_submission_reports_missing_problem_id(): + client = make_client(lambda _: httpx.Response(200, json={"stat_status_pairs": []})) + + with pytest.raises(LeetcodeAPIError, match='did not include question "9999"'): + asyncio.run(client.async_get_latest_submission("9999", "python3")) + + +def test_async_get_latest_submission_scans_paginated_history(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + requests.append(url) + if url == "https://leetcode.com/api/problems/all/": + return httpx.Response( + 200, + json={ + "stat_status_pairs": [ + { + "stat": { + "frontend_question_id": 1, + "question__title_slug": "two-sum", + } + } + ] + }, + ) + if "offset=0" in url: + return httpx.Response( + 200, + json={ + "submissions_dump": [ + { + "title_slug": "add-two-numbers", + "status_display": "Accepted", + "lang": "python3", + "timestamp": 123, + "code": "other code", + } + ], + "has_next": True, + "last_key": "next", + }, + ) + return httpx.Response( + 200, + json={ + "submissions_dump": [ + { + "title_slug": "two-sum", + "status_display": "Accepted", + "lang": "python3", + "timestamp": 122, + "code": "target code", + } + ], + "has_next": False, + "last_key": "", + }, + ) + + client = make_client(handler) + + assert asyncio.run(client.async_get_latest_submission("1", "python3")) == "target code" + assert requests[-1] == "https://leetcode.com/api/submissions/?offset=20&limit=20&lastkey=next" + + +def test_get_latest_submission_reports_missing_history(): + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == "https://leetcode.com/api/problems/all/": + return httpx.Response( + 200, + json={ + "stat_status_pairs": [ + { + "stat": { + "frontend_question_id": 1, + "question__title_slug": "two-sum", + } + } + ] + }, + ) + return httpx.Response(200, json={"submissions_dump": [], "has_next": False, "last_key": ""}) + + client = make_client(handler) + + with pytest.raises(LeetcodeAPIError, match="Could not find"): + client.get_latest_submission("1", "python3") + + +def test_async_submit_question_polls_until_success(monkeypatch): + requests = [] + + async def fake_sleep(delay: float) -> None: + assert delay == 1 + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, str(request.url))) + + if request.method == "POST": + payload = json.loads(request.content) + assert payload["question_id"] == 1 + assert payload["judge_type"] == "large" + return httpx.Response(200, json={"interpret_id": "runcode_123"}) + + return httpx.Response( + 200, + json={ + "state": "SUCCESS", + "status_msg": "Accepted", + "status_code": 10, + "status_runtime": "1 ms", + "status_memory": "1 MB", + "code_output": ["[0,1]"], + }, + ) + + monkeypatch.setattr("leet2git.leetcode_client.asyncio.sleep", fake_sleep) + client = make_client(handler) + + asyncio.run( + client.async_submit_question( + "class Solution: ...", + 1, + "two-sum", + "python3", + is_test=True, + test_input="[2,7,11,15]\n9", + ) + ) + + assert requests == [ + ("POST", "https://leetcode.com/problems/two-sum/interpret_solution/"), + ("GET", "https://leetcode.com/submissions/detail/runcode_123/check/"), + ] + + +@pytest.mark.parametrize( + ("status_payload", "expected_output"), + [ + ( + { + "state": "SUCCESS", + "status_msg": "Wrong Answer", + "status_code": 11, + "input_formatted": "[1,2]", + "expected_output": "3", + "code_output": "4", + }, + ["Last Input: [1,2]", "Expected Output: 3", "Code Output: 4"], + ), + ( + { + "state": "SUCCESS", + "status_msg": "Time Limit Exceeded", + "status_code": 14, + "last_testcase": "[1,2]\n3", + "expected_output": "3", + "code_output": "timeout", + }, + ["Last Input: [1,2] 3", "Expected Output: 3", "Code Output: timeout"], + ), + ( + { + "state": "SUCCESS", + "status_msg": "Runtime Error", + "status_code": 15, + "runtime_error": "IndexError", + }, + ["Runtime Error: IndexError"], + ), + ( + { + "state": "SUCCESS", + "status_msg": "Compile Error", + "status_code": 20, + "compile_error": "SyntaxError", + }, + ["Compile Error: SyntaxError"], + ), + ], +) +def test_async_submit_question_reports_non_accepted_result_shapes( + monkeypatch, + capsys, + status_payload, + expected_output, +): + async def fake_sleep(delay: float) -> None: + assert delay == 1 + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(200, json={"interpret_id": "runcode_123"}) + return httpx.Response(200, json=status_payload) + + monkeypatch.setattr("leet2git.leetcode_client.asyncio.sleep", fake_sleep) + client = make_client(handler) + + asyncio.run( + client.async_submit_question( + "class Solution: ...", + 1, + "two-sum", + "python3", + is_test=True, + test_input="[1,2]", + ) + ) + + output = capsys.readouterr().out + for expected in expected_output: + assert expected in output + + +def test_async_submit_question_uses_real_submit_endpoint_and_percentiles(monkeypatch, capsys): + requests = [] + + async def fake_sleep(delay: float) -> None: + assert delay == 1 + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, str(request.url), json.loads(request.content or b"{}"))) + if request.method == "POST": + return httpx.Response(200, json={"submission_id": 456}) + return httpx.Response( + 200, + json={ + "state": "SUCCESS", + "status_msg": "Accepted", + "status_code": 10, + "status_runtime": "2 ms", + "runtime_percentile": 98.765, + "status_memory": "18 MB", + "memory_percentile": 88.123, + }, + ) + + monkeypatch.setattr("leet2git.leetcode_client.asyncio.sleep", fake_sleep) + client = make_client(handler) + + client.submit_question("class Solution: ...", 1, "two-sum", "python3") + + assert requests == [ + ( + "POST", + "https://leetcode.com/problems/two-sum/submit/", + {"question_id": 1, "lang": "python3", "typed_code": "class Solution: ..."}, + ), + ("GET", "https://leetcode.com/submissions/detail/456/check/", {}), + ] + output = capsys.readouterr().out + assert "Better than 98.77%" in output + assert "Better than 88.12%" in output + + +def test_request_json_wraps_http_errors(): + client = make_client(lambda _: httpx.Response(403, json={"error": "forbidden"})) + + with pytest.raises(LeetcodeAPIError, match="HTTP 403"): + asyncio.run(client._request_json("GET", "https://leetcode.com/api/private", DummyResponse)) + + +def test_request_json_rejects_non_json_response(): + client = make_client(lambda _: httpx.Response(200, text="")) + + with pytest.raises(LeetcodeAPIError, match="non-JSON"): + asyncio.run(client._request_json("GET", "https://leetcode.com/api/private", DummyResponse)) + + +def test_request_json_wraps_request_errors(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("network down", request=request) + + client = make_client(handler) + + with pytest.raises(LeetcodeAPIError, match="Could not reach LeetCode"): + asyncio.run(client._request_json("GET", "https://leetcode.com/api/private", DummyResponse)) + + +def test_sync_wrapper_closes_coroutines_inside_running_event_loop(): + client = make_client(lambda _: httpx.Response(200, json={})) + + class FakeCoroutine: + closed = False + + def close(self): + self.closed = True + + async def run_in_loop(): + fake_coroutine = FakeCoroutine() + with pytest.raises(LeetcodeAPIError, match="active event loop"): + client._run_async(fake_coroutine) + return fake_coroutine + + fake_coroutine = asyncio.run(run_in_loop()) + + assert fake_coroutine.closed is True + + +def test_submission_list_requires_expected_shape(): + client = make_client(lambda _: httpx.Response(200, json={"submissions_dump": []})) + + with pytest.raises(LeetcodeAPIError, match="has_next"): + asyncio.run(client.async_get_submission_list()) + + +def test_submission_list_retries_transient_403(monkeypatch): + calls = 0 + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return httpx.Response(403, text="Just a moment...") + return httpx.Response( + 200, + json={"submissions_dump": [], "has_next": False, "last_key": ""}, + ) + + monkeypatch.setattr("leet2git.leetcode_client.asyncio.sleep", fake_sleep) + client = make_client(handler) + + response = asyncio.run(client.async_get_submission_list()) + + assert response.has_next is False + assert calls == 2 + assert sleeps == [5] + + +def test_get_question_data_validates_question_payload(): + client = make_client(lambda _: httpx.Response(200, json={"data": {"question": None}})) + + with pytest.raises(LeetcodeAPIError, match="two-sum"): + client.get_question_data(1, "two-sum", "python3") + + +def test_get_question_data_requires_language_snippet(): + question_payload = { + "data": { + "question": { + "questionId": "1", + "title": "Two Sum", + "titleSlug": "two-sum", + "difficulty": "Easy", + "codeSnippets": [{"langSlug": "java", "code": "class Solution {}"}], + "topicTags": [], + "content": "

desc

", + "sampleTestCase": "[1]\n1", + "exampleTestcases": "[1]\n1", + } + } + } + client = make_client(lambda _: httpx.Response(200, json=question_payload)) + + with pytest.raises(LeetcodeAPIError, match="python3"): + client.get_question_data(1, "two-sum", "python3") diff --git a/tests/test_my_utils.py b/tests/test_my_utils.py new file mode 100644 index 0000000..5ba7855 --- /dev/null +++ b/tests/test_my_utils.py @@ -0,0 +1,71 @@ +from leet2git.my_utils import get_question_id, reset_config, wait_to_finish_download +from leet2git.question_db import QuestionData + + +class FakeConfigManager: + def __init__(self): + self.reset_calls = [] + self.config = type("Config", (), {"source_path": "/old/repo"})() + + def reset_config(self, source_repository, language): + self.reset_calls.append((source_repository, language)) + + +def test_reset_config_reuses_existing_source_path(): + manager = FakeConfigManager() + + reset_config(manager, "", "python3") + + assert manager.reset_calls == [("/old/repo", "python3")] + + +def test_get_question_id_refreshes_unknown_slug_mapping(): + class FakeQuestionDB: + def __init__(self): + self.saved = False + self.title_to_id = {} + + def check_if_id_is_known(self, title_slug): + return title_slug in self.title_to_id + + def set_id_title_map(self, id_title_map): + self.title_to_id = id_title_map.title_to_id + + def save(self): + self.saved = True + + def get_id_from_title(self, title_slug): + return self.title_to_id[title_slug] + + class FakeClient: + def get_id_title_map(self): + return type("IdTitleMap", (), {"title_to_id": {"two-sum": 1}})() + + qdb = FakeQuestionDB() + + assert get_question_id("two-sum", qdb, FakeClient()) == 1 + assert qdb.saved is True + + +def test_wait_to_finish_download_joins_jobs_and_stores_results(): + class FakeProcess: + joined = False + + def join(self): + self.joined = True + + class FakeQuestionDB: + def __init__(self): + self.questions = [] + + def add_question(self, question): + self.questions.append(question) + + process = FakeProcess() + question_db = FakeQuestionDB() + + imported_count = wait_to_finish_download([process], {1: QuestionData(id=1)}, question_db) + + assert imported_count == 1 + assert process.joined is True + assert question_db.questions == [QuestionData(id=1)] diff --git a/tests/test_python_handler.py b/tests/test_python_handler.py new file mode 100644 index 0000000..93daeea --- /dev/null +++ b/tests/test_python_handler.py @@ -0,0 +1,173 @@ +import pytest + +from leet2git.config_manager import AppConfig +from leet2git.config_manager import TestCodeConfig as LeetTestCodeConfig +from leet2git.file_handler import create_file_handler +from leet2git.python_handler import PythonHandler +from leet2git.question_db import QuestionData + + +def make_handler( + question_data: QuestionData | None = None, + config: AppConfig | None = None, +) -> PythonHandler: + handler = PythonHandler() + handler.set_data( + question_data or QuestionData(question_template="class Solution:\n def twoSum(self):\n"), + config or AppConfig(), + ) + return handler + + +def test_python_handler_imports_without_file_handler_side_effect(): + handler = PythonHandler() + + assert handler.languages == ["python", "python3"] + + +def test_create_file_handler_uses_lazy_registry(): + handler = create_file_handler(QuestionData(language="python3"), AppConfig(language="python3")) + fallback = create_file_handler(QuestionData(language="rust"), AppConfig(language="rust")) + + assert type(handler).__name__ == "PythonHandler" + assert type(fallback).__name__ == "DefaultHandler" + + +def test_get_function_name_requires_function_template(): + handler = make_handler(QuestionData(question_template="class Solution:\n pass\n")) + + with pytest.raises(ValueError, match="function"): + handler.get_function_name() + + +def test_get_function_name_supports_constructor_style_template(): + handler = make_handler( + QuestionData(question_template="class LRUCache:\n def __init__(self, capacity: int):\n") + ) + + assert handler.get_function_name() == ["LRUCache"] + + +def test_generate_source_creates_python_file_without_tests_when_disabled(tmp_path, monkeypatch): + question = QuestionData( + id=1, + title="Two Sum", + difficulty="Easy", + url="https://leetcode.com/problems/two-sum", + file_path="src/leetcode_1_two_sum", + language="python3", + question_template="class Solution:\n def twoSum(self, nums, target):\n", + ) + config = AppConfig( + source_path=str(tmp_path), + test_code=LeetTestCodeConfig(generate_tests=False), + ) + handler = make_handler(question, config) + monkeypatch.setattr("leet2git.python_handler.fix_files", lambda _: None) + monkeypatch.setattr(handler, "run_formatter", lambda _: None) + + file_path = handler.generate_source() + + generated = tmp_path / file_path + assert generated.exists() + content = generated.read_text(encoding="UTF8") + assert "def twoSum" in content + assert " pass" in content + assert 'if __name__ == "__main__"' not in content + + +def test_generate_source_adds_test_entrypoint_when_enabled(tmp_path, monkeypatch): + question = QuestionData( + id=1, + title="Two Sum", + difficulty="Easy", + url="https://leetcode.com/problems/two-sum", + file_path="src/leetcode_1_two_sum", + language="python3", + question_template="class Solution:\n def twoSum(self, nums, target):\n", + ) + handler = make_handler(question, AppConfig(source_path=str(tmp_path))) + monkeypatch.setattr("leet2git.python_handler.fix_files", lambda _: None) + monkeypatch.setattr(handler, "run_formatter", lambda _: None) + + file_path = handler.generate_source() + + content = (tmp_path / file_path).read_text(encoding="UTF8") + assert 'if __name__ == "__main__"' in content + assert "pytest.main([os.path.join('tests', 'test_1.py')])" in content + + +def test_generate_tests_creates_single_function_pytest_file(tmp_path, monkeypatch): + question = QuestionData( + id=1, + title="Two Sum", + file_path="src/leetcode_1_two_sum.py", + language="python3", + function_name=["twoSum"], + inputs=["[2,7,11,15], 9", "[3,2,4], 6"], + outputs=["[0, 1]", "[1, 2]"], + ) + handler = make_handler(question, AppConfig(source_path=str(tmp_path))) + monkeypatch.setattr(handler, "run_formatter", lambda _: None) + + test_file_path = handler.generate_tests() + + content = (tmp_path / test_file_path).read_text(encoding="UTF8") + assert test_file_path == "tests/test_1.py" + assert "from src.leetcode_1_two_sum import Solution" in content + assert "twoSum([2,7,11,15], 9) == [0, 1]" in content + assert "twoSum([3,2,4], 6) == [1, 2]" in content + + +def test_generate_tests_requires_function_name(tmp_path): + handler = make_handler( + QuestionData( + id=1, + file_path="src/leetcode_1_two_sum.py", + language="python3", + inputs=["[2,7,11,15], 9"], + outputs=["[0, 1]"], + ), + AppConfig(source_path=str(tmp_path)), + ) + + with pytest.raises(ValueError, match="No function name"): + handler.generate_tests() + + +def test_generate_submission_file_strips_generated_main_block(tmp_path): + question = QuestionData(file_path="src/leetcode_1_two_sum.py") + full_path = tmp_path / question.file_path + full_path.parent.mkdir() + full_path.write_text( + "class Solution:\n pass\n\nif __name__ == \"__main__\":\n print('run')\n", + encoding="UTF8", + ) + handler = make_handler(question, AppConfig(source_path=str(tmp_path))) + + assert handler.generate_submission_file() == "class Solution:\n pass\n\n" + + +def test_run_formatter_skips_when_ruff_is_unavailable(monkeypatch, capsys): + handler = make_handler() + monkeypatch.setattr("leet2git.python_handler.shutil.which", lambda _: None) + + handler.run_formatter("solution.py") + + assert "ruff is not installed" in capsys.readouterr().out + + +def test_run_formatter_invokes_ruff_without_shell(monkeypatch): + handler = make_handler() + calls = [] + monkeypatch.setattr("leet2git.python_handler.shutil.which", lambda _: "/usr/bin/ruff") + monkeypatch.setattr( + "leet2git.python_handler.subprocess.run", + lambda args, **kwargs: calls.append((args, kwargs)), + ) + + handler.run_formatter("solution.py") + + assert calls[0][0] == ["ruff", "check", "--fix", "--select", "I,UP,F401", "solution.py"] + assert calls[1][0] == ["ruff", "format", "solution.py"] + assert all("shell" not in kwargs for _, kwargs in calls) diff --git a/tests/test_question_db.py b/tests/test_question_db.py new file mode 100644 index 0000000..c7f4982 --- /dev/null +++ b/tests/test_question_db.py @@ -0,0 +1,76 @@ +import pickle + +from leet2git.config_manager import AppConfig +from leet2git.question_db import IdTitleMap, QuestionData, QuestionDB, TopicTag + + +def make_config(tmp_path): + return AppConfig(source_path=str(tmp_path / "solutions"), legacy_data_path=str(tmp_path / "legacy")) + + +def test_question_data_uses_isolated_defaults(): + first = QuestionData(id=1) + second = QuestionData(id=2) + + first.inputs.append("[1, 2]") + first.categories.append(TopicTag(name="Array", slug="array")) + + assert second.inputs == [] + assert second.categories == [] + + +def test_question_data_validates_and_coerces_fields(): + question = QuestionData.model_validate( + { + "id": "42", + "internal_id": "9001", + "creation_time": "12.5", + "function_name": ["twoSum"], + } + ) + + assert question.id == 42 + assert question.internal_id == 9001 + assert question.creation_time == 12.5 + assert question.function_name == ["twoSum"] + + +def test_question_db_round_trips_pydantic_models(tmp_path): + config = make_config(tmp_path) + question_db = QuestionDB(config) + question_db.add_question(QuestionData(id=1, title="Two Sum")) + question_db.set_id_title_map(IdTitleMap(id_to_title={1: "two-sum"}, title_to_id={"two-sum": 1})) + question_db.save() + + loaded_db = QuestionDB(config) + loaded_db.load() + + assert loaded_db.get_question(1) == QuestionData(id=1, title="Two Sum") + assert loaded_db.get_title_from_id(1) == "two-sum" + assert loaded_db.get_id_from_title("two-sum") == 1 + + +def test_question_db_loads_legacy_dict_payloads(tmp_path): + config = make_config(tmp_path) + question_db = QuestionDB(config) + legacy_path = tmp_path / "legacy" + legacy_path.mkdir() + + with open(question_db.legacy_db_file, "wb") as file: + pickle.dump({1: {"id": "1", "title": "Two Sum"}}, file) + with open(question_db.legacy_id_title_map_file, "wb") as file: + pickle.dump({"id_to_title": {"1": "two-sum"}, "title_to_id": {"two-sum": "1"}}, file) + + question_db.load() + + assert question_db.get_question(1) == QuestionData(id=1, title="Two Sum") + assert question_db.get_title_from_id(1) == "two-sum" + assert question_db.get_id_from_title("two-sum") == 1 + assert question_db.migrated_from_legacy is True + + +def test_question_data_unpickles_dataclass_style_state(): + question = QuestionData.__new__(QuestionData) + question.__setstate__({"id": "7", "title": "Reverse Integer"}) + + assert question == QuestionData(id=7, title="Reverse Integer") diff --git a/tests/test_readme_handler.py b/tests/test_readme_handler.py new file mode 100644 index 0000000..a7030fe --- /dev/null +++ b/tests/test_readme_handler.py @@ -0,0 +1,60 @@ +from leet2git.config_manager import AppConfig, ReadmeConfig +from leet2git.question_db import QuestionData, TopicTag +from leet2git.readme_handler import ReadmeHandler + + +def test_readme_handler_builds_summary_difficulty_and_category_tables(tmp_path): + config = AppConfig(source_path=str(tmp_path)) + questions = [ + QuestionData( + id=1, + title="Two Sum", + difficulty="Easy", + url="https://leetcode.com/problems/two-sum", + file_path="src/leetcode_1_two_sum.py", + categories=[TopicTag(name="Array", slug="array")], + ), + QuestionData( + id=2, + title="Add Two Numbers", + difficulty="Medium", + url="https://leetcode.com/problems/add-two-numbers", + file_path="src/leetcode_2_add_two_numbers.py", + categories=[TopicTag(name="Linked List", slug="linked-list")], + ), + ] + + ReadmeHandler(config).build_readme(questions) + + readme = (tmp_path / "README.md").read_text(encoding="UTF8") + assert "# Solution Summary" in readme + assert "[Two Sum](src/leetcode_1_two_sum.py)" in readme + assert "[Easy](#Easy)" in readme + assert "# Difficulty" in readme + assert '## Array' in readme + assert "[Array](#array)" in readme + + +def test_readme_handler_can_disable_anchor_sections(tmp_path): + config = AppConfig( + source_path=str(tmp_path), + readme=ReadmeConfig(show_difficulty=False, show_category=False), + ) + question = QuestionData( + id=1, + title="Two Sum", + difficulty="Easy", + url="https://leetcode.com/problems/two-sum", + file_path="src/leetcode_1_two_sum.py", + categories=[TopicTag(name="Array", slug="array")], + ) + + ReadmeHandler(config).build_readme([question]) + + readme = (tmp_path / "README.md").read_text(encoding="UTF8") + assert "[Difficulty](#difficulty)" not in readme + assert "[Categories](#categories)" not in readme + assert ( + "|1|[Two Sum](src/leetcode_1_two_sum.py)|[1](https://leetcode.com/problems/two-sum)|Array|Easy|" + in readme + ) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..d98e425 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,47 @@ +from leet2git import version + + +def test_version_info_includes_runtime_details(): + info = version.version_info() + + assert "leet2git version:" in info + assert "python version:" in info + assert "platform:" in info + + +def test_find_version_line_uses_ast_assignment(): + content = 'OTHER = "__version__ = not this"\n__version__ = "1.2.3"\n' + + assert version._find_version_line(content) == 2 + + +def test_update_version_string_accepts_v_prefix(tmp_path, monkeypatch): + version_file = tmp_path / "version.py" + version_file.write_text('__version__ = "0.1.0"\n', encoding="UTF8") + monkeypatch.setattr(version, "__file__", str(version_file)) + + version.update_version_string("v1.2.3") + + assert version_file.read_text(encoding="UTF8") == '__version__ = "1.2.3"\n' + + +def test_update_version_string_rejects_invalid_version(tmp_path, monkeypatch, capsys): + version_file = tmp_path / "version.py" + version_file.write_text('__version__ = "0.1.0"\n', encoding="UTF8") + monkeypatch.setattr(version, "__file__", str(version_file)) + + version.update_version_string("1.2") + + assert version_file.read_text(encoding="UTF8") == '__version__ = "0.1.0"\n' + assert "Version 1.2 is not valid" in capsys.readouterr().out + + +def test_update_version_string_reports_missing_assignment(tmp_path, monkeypatch, capsys): + version_file = tmp_path / "version.py" + version_file.write_text('VERSION = "0.1.0"\n', encoding="UTF8") + monkeypatch.setattr(version, "__file__", str(version_file)) + + version.update_version_string("1.2.3") + + assert version_file.read_text(encoding="UTF8") == 'VERSION = "0.1.0"\n' + assert "Could not find __version__" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6096ab0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1044 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "autoflake" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/0b/70c277eef225133763bf05c02c88df182e57d5c5c0730d3998958096a82e/autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504", size = 16515, upload-time = "2026-02-20T05:01:43.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/21/26f1680ec3a598ea31768f9ebcd427e42986d077a005416094b580635532/autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53", size = 17715, upload-time = "2026-02-20T05:01:42.137Z" }, +] + +[[package]] +name = "autoimport" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autoflake" }, + { name = "click" }, + { name = "maison" }, + { name = "pyprojroot" }, + { name = "sh" }, + { name = "xdg" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/26/9ba25725f32c446af66ac336a262b3237ac07f6359ab91f0753c0f3892c2/autoimport-1.6.1.tar.gz", hash = "sha256:0c8b2656209a003fc7e2b6805c5e91770651cbe1e60edf1aaa14beae2405ae26", size = 31768, upload-time = "2024-10-01T16:15:27.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/b0/a5b37094c2b9f202061825e72584a7c25bd86cbb3b2df4b12e09652602a5/autoimport-1.6.1-py3-none-any.whl", hash = "sha256:8f9914576b07d408f2303783fe4fe85b8f9ef48f3771563810f4ea4a42c72af5", size = 24021, upload-time = "2024-10-01T16:15:26.058Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "browser-cookie3" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jeepney", marker = "'bsd' in sys_platform or sys_platform == 'linux'" }, + { name = "lz4" }, + { name = "pycryptodomex" }, + { name = "shadowcopy", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/e1/652adea0ce25948e613ef78294c8ceaf4b32844aae00680d3a1712dde444/browser_cookie3-0.20.1.tar.gz", hash = "sha256:6d8d0744bf42a5327c951bdbcf77741db3455b8b4e840e18bab266d598368a12", size = 22665, upload-time = "2024-12-20T00:31:30.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/57/2a716f4ecf6c50b2dbe27439507c480bb7ca5725edef82349ecdcfcdd084/browser_cookie3-0.20.1-py3-none-any.whl", hash = "sha256:4b38bf669d386250733c8339f0036e1cf09c3d8e4d326fd507b9afb84def13d6", size = 17229, upload-time = "2025-01-04T14:46:14.753Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "leet2git" +source = { editable = "." } +dependencies = [ + { name = "autoimport" }, + { name = "beautifulsoup4" }, + { name = "browser-cookie3" }, + { name = "click" }, + { name = "gitpython" }, + { name = "httpx" }, + { name = "platformdirs" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +dev = [ + { name = "build" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-custom-exit-code" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "autoimport", specifier = ">=1.6.1" }, + { name = "beautifulsoup4", specifier = ">=4.14.3" }, + { name = "browser-cookie3", specifier = ">=0.20.1" }, + { name = "build", marker = "extra == 'dev'", specifier = ">=1.3.0" }, + { name = "click", specifier = ">=8.3.1" }, + { name = "gitpython", specifier = ">=3.1.45" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.0" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.1.1" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" }, + { name = "pytest-custom-exit-code", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.18" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.51" }, +] +provides-extras = ["dev"] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + +[[package]] +name = "maison" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/51/004aa562534a0faad195e6166c4c3b4a5d9b79a3858a3020760d55dfc8ac/maison-1.4.3.tar.gz", hash = "sha256:766222ce82ae27138256c4af9d0bc6b3226288349601e095dcc567884cf0ce36", size = 12999, upload-time = "2024-01-11T22:57:40.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/51/ada0e1f70a2e9be848fbbf47e69410a989609dd6b5b2b5795cfdf80636f3/maison-1.4.3-py3-none-any.whl", hash = "sha256:a36208d0befb3bd8aa3b002ac198ce6f6e61efe568b195132640f4032eff46ac", size = 10487, upload-time = "2024-01-11T22:57:38.834Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyprojroot" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/7f/d04044efe4acc4185db1174209fadac33cc21c015ed0d6bef8884c9fa808/pyprojroot-0.3.0.tar.gz", hash = "sha256:109705bb790968704958efcfc5ccce85d8e3dafa054897cc81371fcbbf56cb10", size = 6287, upload-time = "2023-03-13T05:39:30.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/9b/eef01392be945c0fe86a8d084ba9188b1e2b22af037d7109b9f40a962cd0/pyprojroot-0.3.0-py3-none-any.whl", hash = "sha256:c426b51b17ab4f4d4f95b479cf5b6c22df59bb58fbd4f01b37a6977d29b99888", size = 7558, upload-time = "2023-03-13T05:39:28.707Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-custom-exit-code" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/9d/e1eb0af5e96a5c34f59b9aa69dfb680764420fe60f2ec28cfbc5339f99f8/pytest-custom_exit_code-0.3.0.tar.gz", hash = "sha256:51ffff0ee2c1ddcc1242e2ddb2a5fd02482717e33a2326ef330e3aa430244635", size = 3633, upload-time = "2019-08-07T09:45:15.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a0/effb6cbbccfd1c106c572d3d619b3418d71093afb4cd4f91f51e6a1799d2/pytest_custom_exit_code-0.3.0-py3-none-any.whl", hash = "sha256:6e0ce6e57ce3a583cb7e5023f7d1021e19dfec22be41d9ad345bae2fc61caf3b", size = 4055, upload-time = "2019-08-07T09:45:13.767Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +] + +[[package]] +name = "sh" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/56/6cedf4704590ac9c8ab57089ffa95895c21e82499981008ad369c43c9cc4/sh-2.3.0.tar.gz", hash = "sha256:402af9087bf8a5557562913ca83d715bfa0646cb93865c5d60c5578b07b17871", size = 135995, upload-time = "2026-06-09T04:22:34.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/85/d06247fc88ba278227397da5604287e4762f130347dc968f6d205eb15ee6/sh-2.3.0-py3-none-any.whl", hash = "sha256:9b9c51f988fa91ba09e2a78edc48810d0b5a34c8cf383408e4e8c4352169f26e", size = 48045, upload-time = "2026-06-09T04:22:33.069Z" }, +] + +[[package]] +name = "shadowcopy" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wmi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/44/c00420a3b7bcdf830529b933392b566c876a4944a24f31e126eb6fd28647/shadowcopy-0.0.4.tar.gz", hash = "sha256:ed89817dda065f893607a04c0b7d6b3b34c3507a4711f441111a4bcb1b1826c0", size = 4138, upload-time = "2023-07-08T00:01:35.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/32/fdea8e7f7b2b8bae13ee6ab7df1a10d28a24dbeb03a62ff033563f95d77c/shadowcopy-0.0.4-py3-none-any.whl", hash = "sha256:fc51e59a639dc6a5a3a7a9b4e3ecadc71989e339f2d995d90aaa491acd4ba4eb", size = 4212, upload-time = "2023-07-08T00:01:34.042Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.51" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ce/352fcdba5c72ea20e5d2e46e28809cdb617575b71209d971eff2ace8e6c4/ty-0.0.51.tar.gz", hash = "sha256:b90172d46365bb9d51a7011cbb5c60cc4f514f42c86635df6c092b717f85e1ac", size = 5953151, upload-time = "2026-06-19T01:48:58.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/8f/8fe7cab79a45320b2cdcd602f16d44c8108d2f418ff7ec316c6212f1f0cc/ty-0.0.51-py3-none-linux_armv6l.whl", hash = "sha256:947986bd82d324b3a5c58ce03f1dad160cdf36443d3e8f64b3484b861ba9bc64", size = 11884805, upload-time = "2026-06-19T01:48:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/56fdc39a3f44c0564fd157e1e59e1f9c3fc5ba57ae4472ded85c67c63d74/ty-0.0.51-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb", size = 11633593, upload-time = "2026-06-19T01:48:22.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/57/136e83f24fc04f5afdcabff42f40fa27eae5ac3f0e3f12627d072a55f679/ty-0.0.51-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2faed19a8f1505370de071c008df52a994fc03a204f3267c3a33a32ca26f854f", size = 11063076, upload-time = "2026-06-19T01:48:25.223Z" }, + { url = "https://files.pythonhosted.org/packages/32/f8/5d32f0df5692446440ab781b9b119aa3e0c0dbfa78c583fe9be8417d54fa/ty-0.0.51-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c", size = 11579542, upload-time = "2026-06-19T01:48:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/4f54ef338e9623886809ecd508931b0cd5b3aba1e591586a2f6aeaa8bd11/ty-0.0.51-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f", size = 11676189, upload-time = "2026-06-19T01:48:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/31729066f9b9d3596941edaf267894eefc0b30df4518f003dba5f7276258/ty-0.0.51-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd92913bc90d1705ef9391ff8c6822b61e2e827fa295eb30bf0dfabcf815645", size = 12188154, upload-time = "2026-06-19T01:48:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/2f/38/d4301aa12d2283c7130908baf1417a37dfe3e10f5669cb4ce2853c2540b4/ty-0.0.51-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:429a997394dac73870d71b87cc90efc54da3efaf319e72ca18aeef35a78aef90", size = 12780597, upload-time = "2026-06-19T01:48:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/4b2e67e53f126d39abe201bd2299e467e27463a284e965ad195cbc217fa0/ty-0.0.51-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62d94f06e8c317e89b6884f2bde443040e596b88c7c79bd944c84c105b06257a", size = 12491115, upload-time = "2026-06-19T01:48:36.169Z" }, + { url = "https://files.pythonhosted.org/packages/74/50/aabfe55c132ebe72b4d639cbf772d931e11b0990d29c1f691922b6ccabc1/ty-0.0.51-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff", size = 12230135, upload-time = "2026-06-19T01:48:38.445Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/9aa428052dbed91c50919cd080426a313cf20ce14c6bfe2b71345e548671/ty-0.0.51-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c1bd1355aee86af01e4e21b0bc16fc460fb05905761f0d8b8d70841de0feade8", size = 12468123, upload-time = "2026-06-19T01:48:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5a/f6ce69f2575259386c950c40e02578d0902760cb61f95045e9971182c24e/ty-0.0.51-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:79d1877e93460f936bc10ed1a31525702b7ce51075763ccba993be17f0b9e905", size = 11541672, upload-time = "2026-06-19T01:48:42.635Z" }, + { url = "https://files.pythonhosted.org/packages/35/3a/2af48924a683e959e95e5cc4dc88e5a8595206a0812b869032b95196f2b0/ty-0.0.51-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c", size = 11694015, upload-time = "2026-06-19T01:48:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/12/899875d8a60b198c8121cb92ce18e18cc072d23ca2130fcdaa176383ef72/ty-0.0.51-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bc7459348a253247bbfb2669a021e614281b86bbea24c36112b8a6e1a2499a16", size = 11832856, upload-time = "2026-06-19T01:48:47.028Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a2/88f681d826d97cc96ef9f6cadd4935f775758944cee07340aa46113bce28/ty-0.0.51-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49a21237f6fd1de56beaff0a3e85fe022a09a3401e67e3abec41ce838a5d4d2e", size = 12333449, upload-time = "2026-06-19T01:48:49.091Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/535a4163b4452c6978c31fedfd7b5803cf3a2253e9455cde350f86638d6a/ty-0.0.51-py3-none-win32.whl", hash = "sha256:61b4b6a003c3ebe53a63a1125c9b6542aa01bc1b6c9a235d01ee328d000d61a9", size = 11177338, upload-time = "2026-06-19T01:48:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4d/2334fbb74291a20129fa7aaa8f789619ec9b6883b27f997b8baa27e4674f/ty-0.0.51-py3-none-win_amd64.whl", hash = "sha256:608d417cd1eaf79bcbd713d9830d5e3db9d57ec225c3af3e4ac9a9ff66b45d70", size = 12325675, upload-time = "2026-06-19T01:48:53.774Z" }, + { url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "wmi" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/66/6364deb0a03415f96c66803d8c4379f808f2401da3bdb183348487b10510/WMI-1.5.1.tar.gz", hash = "sha256:b6a6be5711b1b6c8d55bda7a8befd75c48c12b770b9d227d31c1737dbf0d40a6", size = 26254, upload-time = "2020-04-28T08:22:58.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/b9/a80d1ed4d115dac8e2ac08d16af046a77ab58e3d186e22395bf2add24090/WMI-1.5.1-py2.py3-none-any.whl", hash = "sha256:1d6b085e5c445141c475476000b661f60fff1aaa19f76bf82b7abb92e0ff4942", size = 28912, upload-time = "2020-04-28T08:22:56.055Z" }, +] + +[[package]] +name = "xdg" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/b9/0e6e6f19fb75cf5e1758f4f33c1256738f718966700cffc0fde2f966218b/xdg-6.0.0.tar.gz", hash = "sha256:24278094f2d45e846d1eb28a2ebb92d7b67fc0cab5249ee3ce88c95f649a1c92", size = 3453, upload-time = "2023-02-27T19:27:44.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/54/3516c1cf349060fc3578686d271eba242f10ec00b4530c2985af9faac49b/xdg-6.0.0-py3-none-any.whl", hash = "sha256:df3510755b4395157fc04fc3b02467c777f3b3ca383257397f09ab0d4c16f936", size = 3855, upload-time = "2023-02-27T19:27:42.151Z" }, +]