diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index aabd16e..ddc0945 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,53 +1,31 @@ -name: Deployment +name: Publish to PyPI + on: - push: - branches-ignore: ["*"] - tags: ["*"] + release: + types: [published] + jobs: - build: - name: Build distribution + deploy: runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - - name: Install pypa/build - run: python3 -m pip install build --user - - - name: Build a binary wheel and a source tarball - run: python3 -m build - - - name: Store the distribution packages - uses: actions/upload-artifact@v3 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - publish-to-pypi: - name: Publish Python distribution to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes - needs: - - build - runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/rabbitpy + url: https://pypi.org/project/rabbitpy/ + permissions: + contents: read id-token: write + steps: - - name: Download all the dists - uses: actions/download-artifact@v4.1.7 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Build and check package + run: | + uv build + uvx twine check dist/* + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..f2c9fa1 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,27 @@ +name: Docs +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'rabbitpy/**' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --group docs + + - name: Deploy docs + run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 316019f..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Testing -on: - push: - branches: ["*"] - paths-ignore: - - 'docs/**' - - '*.rst' - tags-ignore: ["*"] -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 5 - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - env: - TEST_HOST: docker - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install testing dependencies - run: pip install -e '.[dev]' - - - name: Bootstrap - run: ./bootstrap.sh - - - name: Run unit tests - run: coverage run - - - name: Generate coverage report - run: coverage report && coverage xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - fail_ci_if_error: true - file: ./build/coverage.xml - flags: unittests - name: codecov-umbrella - token: ${{secrets.CODECOV_TOKEN}} diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 0000000..7e343bf --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,66 @@ +name: Testing +on: + pull_request: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' + tags-ignore: ["*"] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13", "3.14"] + + services: + rabbitmq: + image: rabbitmq:4-management + ports: + - 5672:5672 + - 15672:15672 + options: >- + --health-cmd "rabbitmq-diagnostics -q check_running" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python }} + + - name: Install dependencies + run: uv sync --all-groups + + - name: Write .env for integration tests + run: | + echo "RABBITMQ_URL=amqp://guest:guest@localhost:5672/%2f" >> .env + echo "MANAGEMENT_URL=http://localhost:15672/%2f" >> .env + + - name: Lint Check + run: uv run pre-commit run --all-files + + - name: Run tests + run: uv run coverage run + + - name: Coverage report + run: uv run coverage report + + - name: Coverage XML + run: uv run coverage xml + + - name: Upload Coverage + uses: codecov/codecov-action@v5 + with: + files: ./build/coverage.xml + flags: unittests diff --git a/.gitignore b/.gitignore index ae508ec..6ed6101 100644 --- a/.gitignore +++ b/.gitignore @@ -1,56 +1,9 @@ -# Byte-compiled / optimized / DLL files +.coverage __pycache__/ *.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python env/ -bin/ build/ -develop-eggs/ dist/ -eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ *.egg-info/ -.installed.cfg -*.egg - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.cache -nosetests.xml -coverage.xml - -# Translations -*.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - -# Rope -.ropeproject - -# Django stuff: -*.log -*.pot - -# Sphinx documentation -docs/_build/ - -.idea -.vagrant +tests/data/ca.key +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e2071ca --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-ast + - id: check-toml + - id: check-yaml + args: [--allow-multiple-documents] + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.5 + hooks: + - id: ruff-format + - id: ruff + args: [--fix] + + - repo: local + hooks: + - id: mypy + name: mypy + entry: uv run --frozen mypy + language: system + types: [python] + args: [rabbitpy/, tests/] + pass_filenames: false + + - id: basedpyright + name: basedpyright + entry: uv run --frozen basedpyright + language: system + types: [python] + pass_filenames: false diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 29e0dde..0000000 --- a/.pylintrc +++ /dev/null @@ -1,379 +0,0 @@ -[MASTER] - -# Specify a configuration file. -#rcfile= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Pickle collected data for later comparisons. -persistent=yes - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins=pylint.extensions.check_docs - -# Use multiple processes to speed up Pylint. -jobs=1 - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - -# 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= - -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. -optimize-ast=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# 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= - -# 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=coerce-builtin,dict-iter-method,old-octal-literal,old-ne-operator,reduce-builtin,xrange-builtin,dict-view-method,cmp-builtin,raising-string,buffer-builtin,input-builtin,unichr-builtin,coerce-method,backtick,print-statement,old-division,delslice-method,nonzero-method,long-builtin,using-cmp-argument,standarderror-builtin,setslice-method,indexing-exception,file-builtin,parameter-unpacking,suppressed-message,raw_input-builtin,long-suffix,useless-suppression,unpacking-in-except,getslice-method,zip-builtin-not-iterating,hex-method,import-star-module-level,unicode-builtin,map-builtin-not-iterating,apply-builtin,execfile-builtin,next-method-called,cmp-method,metaclass-assignment,reload-builtin,round-builtin,old-raise-syntax,filter-builtin-not-iterating,intern-builtin,no-absolute-import,oct-method,basestring-builtin,range-builtin-not-iterating - - -[REPORTS] - -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - -# Tells whether to display a full report or only the messages -reports=yes - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This 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= - - -[BASIC] - -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression matching correct attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression matching correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - - -[ELIF] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=100 - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# 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 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - -# Maximum number of lines in a module -max-module-lines=1000 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[SIMILARITIES] - -# Minimum lines number of a similarity. -min-similarity-lines=4 - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install 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 private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[TYPECHECK] - -# 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 - -# 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= - -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). This supports can work -# with qualified names. -ignored-classes= - -# 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= - - -[VARIABLES] - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_$|dummy - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# 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 - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# 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=mcs - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=10 - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of statements in function / method body -max-statements=50 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of attributes for a class (see R0902). -max-attributes=15 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - - -[IMPORTS] - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse - -# 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 external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d23521c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# rabbitpy + +A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library. + +## Development + +```bash +uv sync --all-groups # Install all dependencies (dev + docs) +uv run coverage run # Run tests with coverage +uv run coverage report # View coverage report +uv run pre-commit run -a # Run linting +uv run mkdocs serve # Serve docs locally at http://127.0.0.1:8000 +``` + +## Integration Tests + +Integration tests require a running RabbitMQ instance. Use Docker Compose to start one: + +```bash +docker compose up -d +./bootstrap.sh +``` + +This creates `build/test.env` with the RabbitMQ connection URLs needed by the integration tests. + +## Code Style + +- Ruff for linting and formatting (configured in pyproject.toml) +- Single quotes for strings +- 79 character line length diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b7f3648..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include *.rst LICENSE \ No newline at end of file diff --git a/README.rst b/README.rst index ad9bee5..91c3379 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,9 @@ -rabbitpy - RabbitMQ simplified -============================== +rabbitpy +======== A pure python, thread-safe, minimalistic and Pythonic BSD Licensed -AMQP/RabbitMQ library that supports Python 2.7+ and Python 3.4+. +AMQP/RabbitMQ library that supports Python 3.9+. + rabbitpy aims to provide a simple and easy to use API for interfacing with RabbitMQ, minimizing the programming overhead often found in other libraries. @@ -18,12 +19,6 @@ your choice. I prefer pip: pip install rabbitpy -But there's always easy_install: - -.. code:: bash - - easy_install rabbitpy - Documentation ------------- diff --git a/bootstrap b/bootstrap new file mode 100755 index 0000000..25e4a11 --- /dev/null +++ b/bootstrap @@ -0,0 +1,25 @@ +#!/usr/bin/env sh +# +# NAME +# bootstrap -- initialize/update docker environment + +# vim: set ts=2 sts=2 sw=2 et: +set -e + +TEST_HOST=${TEST_HOST:-"127.0.0.1"} + +echo "Integration test host: ${TEST_HOST}" + +get_exposed_port() { + docker compose port $1 $2 | cut -d: -f2 +} + +# Stop any running instances and clean up after them, then pull images +docker compose down --volumes --remove-orphans +docker compose pull -q +docker compose up -d --wait + +cat > .env < build/test-environment<' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/rabbitpy.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/rabbitpy.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/rabbitpy" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/rabbitpy" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/api/amqp.rst b/docs/api/amqp.rst deleted file mode 100644 index c5a6e2d..0000000 --- a/docs/api/amqp.rst +++ /dev/null @@ -1,30 +0,0 @@ -AMQP Adapter -============ -While the core rabbitpy API strives to provide an easy to use, Pythonic interface -for RabbitMQ, some developers may prefer a less opinionated AMQP interface. The -:py:class:`rabbitpy.AMQP` adapter provides a more traditional AMQP client library -API seen in libraries like `pika `_. - -.. versionadded:: 0.26 - -Example -------- -The following example will connect to RabbitMQ and use the :py:class:`rabbitpy.AMQP` -adapter to consume and acknowledge messages. - -.. code:: python - - import rabbitpy - - with rabbitpy.Connection() as conn: - with conn.channel() as channel: - amqp = rabbitpy.AMQP(channel) - - for message in amqp.basic_consume('queue-name'): - print(message) - -API Documentation ------------------ - -.. autoclass:: rabbitpy.AMQP - :members: diff --git a/docs/api/channel.rst b/docs/api/channel.rst deleted file mode 100644 index e782480..0000000 --- a/docs/api/channel.rst +++ /dev/null @@ -1,28 +0,0 @@ -Channel -======= -A :class:`Channel` is created on an active connection using the :meth:`Connection.channel() ` method. Channels can act as normal Python objects: - -.. code:: python - - conn = rabbitpy.Connection() - chan = conn.channel() - chan.enable_publisher_confirms() - chan.close() - -or as a Python context manager (See :pep:`0343`): - -.. code:: python - - with rabbitpy.Connection() as conn: - with conn.channel() as chan: - chan.enable_publisher_confirms() - -When they are used as a context manager with the `with` statement, when your code exits the block, the channel will automatically close, issuing a clean shutdown with RabbitMQ via the ``Channel.Close`` RPC request. - -You should be aware that if you perform actions on a channel with exchanges, queues, messages or transactions that RabbitMQ does not like, it will close the channel by sending an AMQP ``Channel.Close`` RPC request to your application. Upon receipt of such a request, rabbitpy will raise the :doc:`appropriate exception ` referenced in the request. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Channel - :members: diff --git a/docs/api/connection.md b/docs/api/connection.md new file mode 100644 index 0000000..7a6ee51 --- /dev/null +++ b/docs/api/connection.md @@ -0,0 +1,55 @@ +# Connection + +rabbitpy Connection objects are used to connect to RabbitMQ. They provide a +thread-safe connection to RabbitMQ that is used to authenticate and send all +channel based RPC commands over. Connections use +[AMQP URI syntax](http://www.rabbitmq.com/uri-spec.html) for specifying all +of the connection information, including any connection negotiation options, +such as the heartbeat interval. + +## Usage + +A `Connection` can be used as a normal Python object: + +```python +conn = rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2F') +conn.close() +``` + +Or as a Python context manager: + +```python +with rabbitpy.Connection() as conn: + with conn.channel() as channel: + # use channel + pass +``` + +When used as a context manager, the connection is automatically closed when +the block exits. + +If RabbitMQ remotely closes the connection, rabbitpy will raise the appropriate +exception (see [Exceptions](exceptions.md)). + +If heartbeats are enabled (default: 5 minutes) and RabbitMQ does not send a +heartbeat in >= 2 heartbeat intervals, a `ConnectionResetException` will be raised. + +## Connection URL Parameters + +The connection URL follows standard AMQP URI syntax: + +``` +amqp[s]://[user:password@]host[:port]/[vhost][?key=value...] +``` + +Common query parameters: + +| Parameter | Default | Description | +| ----------- | ------- | --------------------------------- | +| `heartbeat` | `300` | Heartbeat interval in seconds | +| `channel_max` | `0` | Maximum number of channels | +| `frame_max` | `131072` | Maximum frame size in bytes | + +## API + +::: rabbitpy.connection.Connection diff --git a/docs/api/connection.rst b/docs/api/connection.rst deleted file mode 100644 index bd24bba..0000000 --- a/docs/api/connection.rst +++ /dev/null @@ -1,29 +0,0 @@ -Connection -========== -rabbitpy Connection objects are used to connect to RabbitMQ. They provide a thread-safe connection to RabbitMQ that is used to authenticate and send all channel based RPC commands over. Connections use `AMQP URI syntax `_ for specifying the all of the connection information, including any connection negotiation options, such as the heartbeat interval. For more information on the various query parameters that can be specified, see the `official documentation `_. - -A :class:`Connection` is a normal python object that you use: - -.. code:: python - - conn = rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2F') - conn.close() - -or it can be used as a Python context manager (See :pep:`0343`): - -.. code:: python - - with rabbitpy.Connection() as conn: - # Foo - -When it is used as a context manager with the `with` statement, when your code exits the block, the connection will automatically close. - -If RabbitMQ remotely closes your connection via the AMQP `Connection.Close` RPC request, rabbitpy will raise the :doc:`appropriate exception ` referenced in the request. - -If heartbeats are enabled (default: 5 minutes) and RabbitMQ does not send a heartbeat request in >= 2 heartbeat intervals, a :py:class:`ConnectionResetException ` will be raised. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Connection - :members: diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md new file mode 100644 index 0000000..d41d8c5 --- /dev/null +++ b/docs/api/exceptions.md @@ -0,0 +1,99 @@ +# Exceptions + +rabbitpy contains two types of exceptions: exceptions specific to rabbitpy +and exceptions raised as a result of a Channel or Connection closure from +RabbitMQ. + +## Example + +```python +import rabbitpy + +try: + with rabbitpy.Connection() as connection: + with connection.channel() as channel: + queue = rabbitpy.Queue(channel, 'exception-test') + queue.durable = True + queue.declare() + queue.durable = False + queue.declare() # raises AMQPPreconditionFailed +except rabbitpy.exceptions.AMQPPreconditionFailed: + print('Queue already exists with different parameters') +``` + +## rabbitpy Exceptions + +::: rabbitpy.exceptions.RabbitpyException + +::: rabbitpy.exceptions.ActionException + +::: rabbitpy.exceptions.ChannelClosedException + +::: rabbitpy.exceptions.ConnectionException + +::: rabbitpy.exceptions.ConnectionClosed + +::: rabbitpy.exceptions.ConnectionResetException + +::: rabbitpy.exceptions.MessageReturnedException + +::: rabbitpy.exceptions.NoActiveTransactionError + +::: rabbitpy.exceptions.NotConsumingError + +::: rabbitpy.exceptions.NotSupportedError + +::: rabbitpy.exceptions.ReceivedOnClosedChannelException + +::: rabbitpy.exceptions.RemoteCancellationException + +::: rabbitpy.exceptions.RemoteClosedChannelException + +::: rabbitpy.exceptions.RemoteClosedException + +::: rabbitpy.exceptions.TooManyChannelsError + +::: rabbitpy.exceptions.UnexpectedResponseError + +## AMQP Exceptions + +These exceptions are raised when RabbitMQ sends a channel or connection close +with a specific AMQP reply code. + +::: rabbitpy.exceptions.AMQPException + +::: rabbitpy.exceptions.AMQPAccessRefused + +::: rabbitpy.exceptions.AMQPChannelError + +::: rabbitpy.exceptions.AMQPCommandInvalid + +::: rabbitpy.exceptions.AMQPConnectionForced + +::: rabbitpy.exceptions.AMQPContentTooLarge + +::: rabbitpy.exceptions.AMQPFrameError + +::: rabbitpy.exceptions.AMQPInternalError + +::: rabbitpy.exceptions.AMQPInvalidPath + +::: rabbitpy.exceptions.AMQPNoConsumers + +::: rabbitpy.exceptions.AMQPNoRoute + +::: rabbitpy.exceptions.AMQPNotAllowed + +::: rabbitpy.exceptions.AMQPNotFound + +::: rabbitpy.exceptions.AMQPNotImplemented + +::: rabbitpy.exceptions.AMQPPreconditionFailed + +::: rabbitpy.exceptions.AMQPResourceError + +::: rabbitpy.exceptions.AMQPResourceLocked + +::: rabbitpy.exceptions.AMQPSyntaxError + +::: rabbitpy.exceptions.AMQPUnexpectedFrame diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst deleted file mode 100644 index c5c157b..0000000 --- a/docs/api/exceptions.rst +++ /dev/null @@ -1,39 +0,0 @@ -Exceptions -========== -rabbitpy contains two types of exceptions, exceptions that are specific to rabbitpy and exceptions that are raised as a result of a Channel or Connection closure from RabbitMQ. These exceptions will be raised to let you know when you have performed an action like redeclared a pre-existing queue with different values. Consider the following example: - -.. code:: python - - >>> import rabbitpy - >>> - >>> with rabbitpy.Connection() as connection: - ... with connection.channel() as channel: - ... queue = rabbitpy.Queue(channel, 'exception-test') - ... queue.durable = True - ... queue.declare() - ... queue.durable = False - ... queue.declare() - ... - Traceback (most recent call last): - File "", line 7, in - File "rabbitpy/connection.py", line 131, in __exit__ - self._shutdown_connection() - File "rabbitpy/connection.py", line 469, in _shutdown_connection - self._channels[chan_id].close() - File "rabbitpy/channel.py", line 124, in close - super(Channel, self).close() - File "rabbitpy/base.py", line 185, in close - self.rpc(frame_value) - File "rabbitpy/base.py", line 199, in rpc - self._write_frame(frame_value) - File "rabbitpy/base.py", line 311, in _write_frame - raise exception - rabbitpy.exceptions.AMQPPreconditionFailed: - -In this example, the channel that was created on the second line was closed and RabbitMQ is raising the :class:`AMQPPreconditionFailed ` exception via RPC sent to your application using the AMQP Channel.Close method. - -.. automodule:: rabbitpy.exceptions - :members: - :private-members: - :undoc-members: - diff --git a/docs/api/exchange.rst b/docs/api/exchange.rst deleted file mode 100644 index 7b9686f..0000000 --- a/docs/api/exchange.rst +++ /dev/null @@ -1,42 +0,0 @@ -Exchange -======== -The :class:`Exchange ` class is used to work with RabbitMQ exchanges on an open channel. The following example shows how you can create an exchange using the :class:`rabbitpy.Exchange` class. - -.. code:: python - - import rabbitpy - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - exchange = rabbitpy.Exchange(channel, 'my-exchange') - exchange.declare() - -In addition, there are four convenience classes (:class:`DirectExchange `, :class:`FanoutExchange `, :class:`HeadersExchange `, and :class:`TopicExchange `) for creating each built-in exchange type in RabbitMQ. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Exchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.DirectExchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.FanoutExchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.HeadersExchange - :members: - :inherited-members: - :member-order: bysource - -.. autoclass:: rabbitpy.TopicExchange - :members: - :inherited-members: - :member-order: bysource diff --git a/docs/api/message.rst b/docs/api/message.rst deleted file mode 100644 index f6188e7..0000000 --- a/docs/api/message.rst +++ /dev/null @@ -1,9 +0,0 @@ -Message -======= -The :class:`Message ` class is used to create messages that you intend to publish to RabbitMQ and is created when a message is received by RabbitMQ by a consumer or as the result of a :meth:`Queue.get() ` request. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Message - :members: diff --git a/docs/api/queue.rst b/docs/api/queue.rst deleted file mode 100644 index 6ed18b8..0000000 --- a/docs/api/queue.rst +++ /dev/null @@ -1,44 +0,0 @@ -Queue -===== -The :class:`Queue ` class is used to work with RabbitMQ queues on an open channel. The following example shows how you can create a queue using the :meth:`Queue.declare ` method. - -.. code:: python - - import rabbitpy - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - queue = rabbitpy.Queue(channel, 'my-queue') - queue.durable = True - queue.declare() - -To consume messages you can iterate over the Queue object itself if the defaults for the :py:meth:`Queue.__iter__() ` method work for your needs: - -.. code:: python - - with conn.channel() as channel: - for message in rabbitpy.Queue(channel, 'example'): - print 'Message: %r' % message - message.ack() - -or by the :py:meth:`Queue.consume() ` method if you would like to specify `no_ack`, `prefetch_count`, or `priority`: - -.. code:: python - - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - for message in queue.consume(): - print 'Message: %r' % message - message.ack() - -.. warning:: If you use either the :py:class:`Queue` as an iterator method or :py:meth:`Queue.consume` method of consuming messages in PyPy, - you must manually invoke :py:meth:`Queue.stop_consuming`. This is due to PyPy not predictably cleaning up after the generator - used for allowing the iteration over messages. Should your code want to test to see if the code is being executed in PyPy, - you can evaluate the boolean ``rabbitpy.PYPY`` constant value. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Queue - :members: - :special-members: diff --git a/docs/api/tx.rst b/docs/api/tx.rst deleted file mode 100644 index b2a9399..0000000 --- a/docs/api/tx.rst +++ /dev/null @@ -1,33 +0,0 @@ -Transactions -============ -The :class:`Tx ` or transaction class implements transactional functionality with RabbitMQ and allows for any AMQP command to be issued, then committed or rolled back. - -It can be used as a normal Python object: - -.. code:: python - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - tx = rabbitpy.Tx(channel) - tx.select() - exchange = rabbitpy.Exchange(channel, 'my-exchange') - exchange.declare() - tx.commit() - -Or as a context manager (See :pep:`0343`) where the transaction will automatically be started and committed for you: - -.. code:: python - - with rabbitpy.Connection() as connection: - with connection.channel() as channel: - with rabbitpy.Tx(channel) as tx: - exchange = rabbitpy.Exchange(channel, 'my-exchange') - exchange.declare() - -In the event of an exception exiting the block when used as a context manager, the transaction will be rolled back for you automatically. - -API Documentation ------------------ - -.. autoclass:: rabbitpy.Tx - :members: diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 95459fa..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,246 +0,0 @@ -# -*- coding: utf-8 -*- -# -# rabbitpy documentation build configuration file, created by -# sphinx-quickstart on Wed Mar 27 18:31:37 2013. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys - -sys.path.insert(0, '../') - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.viewcode', - 'sphinx.ext.autosummary', 'sphinx.ext.intersphinx'] -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'rabbitpy' -copyright = u'2013, Gavin M. Roy' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. - -import rabbitpy -release = rabbitpy.__version__ -version = '.'.join(release.split('.')[0:1]) - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -intersphinx_mapping = {'pamqp': ('https://pamqp.readthedocs.org/en/latest/', - None), - 'python': ('https://docs.python.org/2/', None)} - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'default' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'rabbitpydoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'rabbitpy.tex', u'rabbitpy Documentation', - u'Gavin M. Roy', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'rabbitpy', u'rabbitpy Documentation', - [u'Gavin M. Roy'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'rabbitpy', u'rabbitpy Documentation', - u'Gavin M. Roy', 'rabbitpy', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' diff --git a/docs/examples/consumer.md b/docs/examples/consumer.md new file mode 100644 index 0000000..b14938f --- /dev/null +++ b/docs/examples/consumer.md @@ -0,0 +1,24 @@ +# Message Consumer + +The following example subscribes to a queue named `test` and consumes messages +until CTRL-C is pressed: + +```python +#!/usr/bin/env python +import logging + +import rabbitpy + +URL = 'amqp://guest:guest@localhost:5672/%2f?heartbeat=15' + +logging.basicConfig(level=logging.INFO) + +with rabbitpy.Connection(URL) as conn: + with conn.channel() as channel: + try: + for message in rabbitpy.Queue(channel, 'test'): + message.pprint(True) + message.ack() + except KeyboardInterrupt: + logging.info('Exited consumer') +``` diff --git a/docs/examples/consumer.rst b/docs/examples/consumer.rst deleted file mode 100644 index 0de17e0..0000000 --- a/docs/examples/consumer.rst +++ /dev/null @@ -1,20 +0,0 @@ -Message Consumer -================ -The following example will subscribe to a queue named "example" and consume messages -until CTRL-C is pressed:: - - import rabbitpy - - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - - # Exit on CTRL-C - try: - # Consume the message - for message in queue: - message.pprint(True) - message.ack() - - except KeyboardInterrupt: - print 'Exited consumer' diff --git a/docs/examples/getter.md b/docs/examples/getter.md new file mode 100644 index 0000000..6744d15 --- /dev/null +++ b/docs/examples/getter.md @@ -0,0 +1,18 @@ +# Message Getter + +The following example gets messages one at a time from the `example` queue, +using `len(queue)` to check the queue depth: + +```python +#!/usr/bin/env python +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + queue = rabbitpy.Queue(channel, 'example') + while len(queue) > 0: + message = queue.get() + message.pprint(True) + message.ack() + print(f'There are {len(queue)} more messages in the queue') +``` diff --git a/docs/examples/getter.rst b/docs/examples/getter.rst deleted file mode 100644 index ca56388..0000000 --- a/docs/examples/getter.rst +++ /dev/null @@ -1,16 +0,0 @@ -Message Getter -============== -The following example will get a single message at a time from the "example" queue -as long as there are messages in it. It uses :code:`len(queue)` to check the current -queue depth while it is looping:: - - import rabbitpy - - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - while len(queue) > 0: - message = queue.get() - message.pprint(True) - message.ack() - print('There are {} more messages in the queue'.format(len(queue))) diff --git a/docs/examples/ha_queues.rst b/docs/examples/ha_queues.rst deleted file mode 100644 index be616ed..0000000 --- a/docs/examples/ha_queues.rst +++ /dev/null @@ -1,10 +0,0 @@ -Declaring HA Queues -=================== -The following example will create a HA :py:meth:`queue ` on each node in a RabbitMQ cluster.:: - - import rabbitpy - - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - with conn.channel() as channel: - queue = rabbitpy.Queue(channel, 'example') - queue.ha_declare() diff --git a/docs/examples/publisher.md b/docs/examples/publisher.md new file mode 100644 index 0000000..fcab6c9 --- /dev/null +++ b/docs/examples/publisher.md @@ -0,0 +1,29 @@ +# Publisher + +The following example connects to RabbitMQ and publishes a message to an exchange: + +```python +#!/usr/bin/env python +import datetime +import uuid + +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + exchange = rabbitpy.Exchange(channel, 'example_exchange') + exchange.declare() + + message = rabbitpy.Message( + channel, + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + { + 'content_type': 'text/plain', + 'delivery_mode': 1, + 'message_type': 'Lorem ipsum', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'message_id': str(uuid.uuid4()), + }, + ) + message.publish(exchange, 'test-routing-key') +``` diff --git a/docs/examples/publisher_confirms.rst b/docs/examples/publisher_confirms.rst deleted file mode 100644 index 96cfeb2..0000000 --- a/docs/examples/publisher_confirms.rst +++ /dev/null @@ -1,24 +0,0 @@ -Mandatory Publishing -==================== -The following example uses RabbitMQ's Publisher Confirms feature to allow for validation -that the message was successfully published:: - - import rabbitpy - - # Connect to RabbitMQ on localhost, port 5672 as guest/guest - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - - # Open the channel to communicate with RabbitMQ - with conn.channel() as channel: - - # Turn on publisher confirmations - channel.enable_publisher_confirms() - - # Create the message to publish - message = rabbitpy.Message(channel, 'message body value') - - # Publish the message, looking for the return value to be a bool True/False - if message.publish('test_exchange', 'test-routing-key', mandatory=True): - print 'Message publish confirmed by RabbitMQ' - else: - print 'RabbitMQ indicates message publishing failure' diff --git a/docs/examples/transactional_publisher.rst b/docs/examples/transactional_publisher.rst deleted file mode 100644 index 227256e..0000000 --- a/docs/examples/transactional_publisher.rst +++ /dev/null @@ -1,23 +0,0 @@ -Transactional Publisher -======================== -The following example uses RabbitMQ's Transactions feature to send the message, -then roll it back:: - - import rabbitpy - - # Connect to RabbitMQ on localhost, port 5672 as guest/guest - with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - - # Open the channel to communicate with RabbitMQ - with conn.channel() as channel: - - # Start the transaction - tx = rabbitpy.Tx(channel) - tx.select() - - # Create the message to publish & publish it - message = rabbitpy.Message(channel, 'message body value') - message.publish('test_exchange', 'test-routing-key') - - # Rollback the transaction - tx.rollback() diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..f6fd45e --- /dev/null +++ b/docs/history.md @@ -0,0 +1,123 @@ +# Version History + +## 3.0.0 — *2024-05-08* + +- Change to use `ssl.SSLContext` since `ssl.wrap_socket` was deprecated in Python 3.7 and removed in 3.12 +- Drops support for Python < 3.8 +- Adds support for Python 3.10, 3.11, and 3.12 +- Change tests over to use `coverage` instead of `nose` + +## 2.0.1 — *2019-08-06* + +- Fixed an issue with the IO loop poller on MacOS (#111) + +## 2.0.0 — *2019-04-19* + +- Updated to use pamqp>=2.3,<3: + - Field table keys are now strings and no longer bytes + - In Python 2.7 if a short-string has UTF-8 characters, it will be a unicode object +- Field-table integer encoding changes +- Drops support for Python < 3.4 +- Adds support for Python 3.6 and 3.7 + +## 1.0.0 — *2016-10-27* + +- Reworked Heartbeat logic to send a heartbeat every `interval / 2` seconds when data has not been written to the socket (#70, #74, #98, #99) +- Improved performance when consuming large messages (#104) — *Jelle Aalbers* +- Allow for username and password to be default again (#96, #97) — *Grzegorz Śliwiński* +- Cleanup of Connection and Channel teardown (#103) + +## 0.27.1 — *2016-05-12* + +- Fix a bug where the IO write trigger socketpair is not being cleaned up on close + +## 0.27.0 — *2016-05-11* + +- Added new `SimpleChannel` class +- Exception formatting changes +- Thread locking optimizations +- Connection shutdown cleanup +- `Message` now assigns a UTC timestamp instead of local timestamp if requested (#94) +- Unquote username and password in URI (#93) — *sunbit* +- Connections now allow for a configurable timeout (#84, #85) — *vmarkovtsev* +- Bugfix for `basic_publish` (#92) — *canardleteer* +- Added `args` property to `Connection` (#88) — *vmarkovtsev* +- Fix locale in connection setup from causing hanging (#87) — *vmarkovtsev* +- Fix heartbeat behavior (#69, #70, #74) +- Cancel consuming in case of exceptions (#68) — *kmelnikov* +- Documentation correction (#79) — *jonahbull* + +## 0.26.2 — *2015-03-17* + +- Fix behavior for Basic.Return frames sent from RabbitMQ +- Pin pamqp 1.6.1 fixing an issue with max-channels + +## 0.26.1 — *2015-03-09* + +- Add the ability to interrupt rabbitpy when waiting on a frame (#38) +- Use a custom base class for all Exceptions (#57) — *Jeremy Tillman* +- Fix for consumer example in documentation (#60) — *Michael Becker* +- Add `rabbitpy.amqp` module for unopinionated access to AMQP API +- Refactor how client side heartbeat checking is managed +- Address an issue when client side channel max count is not set +- Clean up handling of remote channel and connection closing +- Fix URI query parameter names to match AMQP URI spec +- PYPY behavior fixes related to garbage collection + +## 0.25.0 — *2014-12-16* + +- Acquire a lock when creating a new channel to fix multi-threaded channel creation (#56) +- Add client side heartbeat checking (#55) +- Fix a bug where Basic.Nack checking was checking for the wrong string +- Add support for Python3 memoryviews for the message body (#50) +- Improve Python3 behavior in `maybe_utf8_encode` + +## 0.24.0 — *2014-12-12* + +- Update to reflect changes in pamqp 1.6.0 + +## 0.23.0 — *2014-11-05* + +- Fix a bug where message body length was assigned before converting unicode to bytes (#49) +- Fix the automatic coercion of header types to UTF-8 encoded bytes (#49) +- Raise `TypeError` if a timestamp property cannot be converted + +## 0.22.0 — *2014-11-04* + +- Address an issue when RabbitMQ is configured with a max-frame-size of 0 (#48) +- Do not lose the traceback when exiting a context manager due to an exception (#46) +- Adds server capability checking in `Channel` methods +- Pin pamqp version range to >= 1.4, < 2.0 + +## 0.21.1 — *2014-10-23* + +- Clean up KQueue issues (#44) +- Remove sockets from KQueue when in error state +- Handle socket connect errors more cleanly (#44) + +## 0.21.0 — *2014-10-21* + +- Address a possible edge case where message frames can be interspersed when publishing in a multi-threaded environment +- Add exception handling around select.error (#43) +- Add a new `opinionated` flag in `Message` construction +- Add wheel distribution + +## 0.20.0 — *2014-10-01* + +- Added support for KQueue and Poll in IOLoop +- Fixed issues with publishing large messages (#37) +- Add exchange property to `Message` (#40) +- Add out-of-band consumer cancellation with `Queue.stop_consuming()` (#38, #39) +- Significantly increase test coverage + +## Earlier Releases + +- **0.19.0** — Fix the socket read/write buffer size (#35) +- **0.18.1** — Fix unicode message body encoding in Python 2 +- **0.18.0** — Make IO thread daemonic; add `Message.redelivered` property +- **0.17.0** — Refactor cross-thread communication for RabbitMQ invoked RPC methods +- **0.16.0** — Fix an issue with `no_ack=True` consumer cancellation; fix exchange and queue unbinding +- **0.15.0** — Change default durability for Exchange and Queue to False +- **0.14.0** — Add support for `authentication_failure_close`; add consumer priorities +- **0.13.0** — Validate heartbeat is always an integer +- **< 0.5.0** — Previously called *rmqid* diff --git a/docs/history.rst b/docs/history.rst deleted file mode 100644 index 3b75559..0000000 --- a/docs/history.rst +++ /dev/null @@ -1,166 +0,0 @@ -Version History ---------------- -- 3.0.0 - released *2024-05-08* - - Change to use `ssl.SSLContext` since `ssl.wrap_socket` was deprecated in Python 3.7 and removed in 3.12 - - Drops support for Python < 3.8 - - Adds support for Python 3.10, 3.11 and 3.12 - - Change tests over to use `coverage` instead of `nose` -- 2.0.1 - released *2019-08-06* - - Fixed an issue with the IO loop poller on MacOS (#111) -- 2.0.0 - released *2019-04-19* - - Updated to use pamqp>=2.3,<3 which has the following implications: - - Field table keys are now strings and no longer bytes. This may be a breaking change means in Python3 keys will always be type str for short strings. This includes frame values and field table values. - - In Python 2.7 if a short-string (key, frame field value, etc) has UTF-8 characters in it, it will be a unicode object. - - field-table integer encoding changes - - Drops support for Python < 3.4 - - Adds support for Python 3.6 and 3.7 -- 1.0.0 - released *2016-10-27* - - Reworked Heartbeat logic to send a heartbeat every ``interval / 2`` seconds when data has not been written to the socket (#70, #74, #98, #99) - - Improved performance when consuming large mesages (#104) - `Jelle Aalbers `_ - - Allow for username and password to be default again (#96, #97) - `Grzegorz Śliwiński `_ - - Cleanup of Connection and Channel teardown (#103) -- 0.27.1 - released *2016-05-12* - - Fix a bug where the IO write trigger socketpair is not being cleaned up on close -- 0.27.0 - released *2016-05-11* - - Added new :class:`~rabbitpy.simple.SimpleChannel` class - - Exception formatting changes - - Thread locking optimizations - - Connection shutdown cleanup - - :class:`~rabbitpy.message.Message` now assigns a UTC timestamp instead of local timestamp to the AMQP message property if requested (#94) - - Unquote username and password in URI (#93) - `sunbit `_ - - Connections now allow for a configurable timeout (#84, #85) - `vmarkovtsev `_ - - Bugfix for :meth:`~rabbitpy.amqp.AMQP.basic_publish` (#92) - `canardleteer `_ - - Added ``args`` property to :class:`~rabbitpy.connection.Connection` (#88) - `vmarkovtsev `_ - - Fix locale in connection setup from causing hanging (#87) - `vmarkovtsev `_ - - Fix heartbeat behavior (#69, #70, #74) - - Cancel consuming in case of exceptions (#68) - `kmelnikov `_ - - Documentation correction (#79) - `jonahbull `_ -- 0.26.2 - released *2015-03-17* - - Fix behavior for Basic.Return frames sent from RabbitMQ - - Pin pamqp 1.6.1 fixing an issue with max-channels -- 0.26.1 - released *2015-03-09* - - Add the ability to interrupt rabbitpy when waiting on a frame (#38) - - Use a custom base class for all Exceptions (#57) Jeremy Tillman - - Fix for consumer example in documentation (#60) Michael Becker - - Add rabbitpy.amqp module for unopinionated access to AMQP API - - Refactor how client side heartbeat checking is managed when no heartbeat frames have been sent from the server. (#58) - - Address an issue when client side channel max count is not set and server side channel max is set to 65535 (#62) - - Clean up handling of remote channel and connection closing - - Clean up context manager exiting for rabbitpy.Queue - - Remove default prefetch count for simple consuming - - Fix URI query parameter names to match AMQP URI spec on rabbitmq.com - - Fix behavior of SSL flags in query parameters (#63, #64) - - PYPY behavior fixes related to garbage collection -- 0.25.0 - released *2014-12-16* - - Acquire a lock when creating a new channel to fix multi-threaded channel creation behavior (#56) - - Add client side heartbeat checking. If 2 heartbeats are missed, a ConnectionResetException exception will be raised (#55) - - Fix a bug where Basic.Nack checking was checking for the wrong string to test for support - - Add support for Python3 memoryviews for the message body when creating a new rabbitpy.Message (#50) - - Improve Python3 behavior in rabbitpy.utils.maybe_utf8_encode: ensure the object being cast as a bytes object with utf-8 encoding is a string -- 0.24.0 - released *2014-12-12* - - Update to reflect changes in pamqp 1.6.0 - - Update how message property data types are retrieved - - Fix tests relying on .__dict__ -- 0.23.0 - released *2014-11-5* - - Fix a bug where message body length was being assigned to the content header prior to converting the unicode string to bytes (#49) - - Add a new rabbitpy.utils.maybe_utf8_encode method for handling strings that may or may not contain unicode (#49) - - Fix the automatic coercion of header types to UTF-8 encoded bytes (#49) - - Fix an integration test that was not cleaning up its queue after itself - - Raise TypeError if a timestamp property can not be converted properly -- 0.22.0 - released *2014-11-4* - - Address an issue when RabbitMQ is configured with a max-frame-size of 0 (#48) - - Do not lose the traceback when exiting a context manager due to a an exception (#46) - - Adds server capability checking in rabbitpy.Channel methods that require RabbitMQ enhancements to the AMQP protocol (Publisher confirms, consumer priorities, & Baisc.Nack). If unsupported functionality is used, a rabbitpy.exceptions.NotSupportedError exception will be raised. - - Pin pamqp version range to >= 1.4, < 2.0 - - Fix wheel distribution -- 0.21.1 - released *2014-10-23* - - Clean up KQueue issues found when troubleshooting #44, checking for socket EOF in flags to detect connection reset - - Remove sockets from KQueue when in error state - - Change behavior when there is a poll exception list - - Handle socket connect errors more cleanly (#44) - - Handle bug for how we pull the error string from an exception in IO.on_error (#44) - - Re-raise exceptions causing the exit of Connection or Channel so they can be cleanly caught (#44) -- 0.21.0 - released *2014-10-21* - - Address a possible edge case where message frames can be interspersed when publishing in a multi-threaded environment - - Add exception handling around select.error (#43) - - Check all frames for Channel.CloseOk when consuming - - Add a new ``opinionated`` flag in rabbitpy.Message construction that deprecates the ``auto_id`` flag - - Add wheel distribution -- 0.20.0 - released *2014-10-01* - - Added support for KQueue and Poll in IOLoop for performance improvements - - Fixed issues with publishing large messages and socket resource availability errors (#37) - - Add exchange property to rabbitpy.Message (#40) - - Fix exception when timestamp is None in received Message (#41) - - Fix rabbitpy.Message.json() in Python 3.4 (#42) - - Add out-of-band consumer cancellation with Queue.stop_consuming() (#38, #39) - - Add new simple method rabbitpy.create_headers_exchange() - - Significantly increase test coverage -- 0.19.0 - released *2014-06-30* - - Fix the socket read/write buffer size (#35) - - Add new flag in channels to use blocking queue.get operations increasing throughput and lowering overhead. -- 0.18.1 - released *2014-05-15* - - Fix unicode message body encoding in Python 2 -- 0.18.0 - released *2014-05-15* - - Make IO thread daemonic - - block on RPC reads for 1 second instead of 100ms - - add the Message.redelivered property -- 0.17.0 - released *2014-04-16* - - Refactor cross-thread communication for RabbitMQ invoked RPC methods - - fix unclean shutdown conditions and cross-thread exceptions -- 0.16.0 - released *2014-04-10* - - Fix an issue with no_ack=True consumer cancellation - - Fix exchange and queue unbinding - - Add wait on the SOCKET_OPENED event when connecting - - Deal with str message body values in Python 3 by casting to bytes and encoding as UTF-8. -- 0.15.1 - released *2014-01-27* - - Fix an issue with Python 3 IO write trigger -- 0.15.0 - released *2014-01-27* - - Change default durability for Exchange and Queue to False - - Fix a SSL connection issue -- 0.14.2 - released *2014-01-23* - - Fix an issue when IPv6 is the default protocol for the box rabbitpy is being used on -- 0.14.1 - released *2014-01-23* - - Assign queue name for RabbitMQ named queues in rabbitpy.Queue.declare -- 0.14.0 - released *2014-01-22* - - Add support for authentication_failure_close - - Add consumer priorities - - Exception cleanup - - Queue consuming via Queue.__iter__ - - Queue & Exchange attributes are no longer private - - Tx objects can be used as a context manager - - Experimental support for Windows. -- 0.13.0 - released *2014-01-17* - - Validate heartbeat is always an integer - - add arguments to Queue for expires, message-ttl, max-length, & dead-lettering -- 0.12.3 - released *2013-12-23* - - Minor Message.pprint() reformatting -- 0.12.2 - released *2013-12-23* - - Add Exchange and Routing Key to Message.pprint, check for empty method frames in Channel._create_message -- 0.12.1 - released *2013-12-19* - - Fix exception with pika.exceptions.AMQP -- 0.12.0 - released *2013-12-19* - - Updated simple consumer to potential one-liner - - Added rabbitpy.Message.pprint() -- 0.11.0 - released *2013-12-19* - - Major bugfix focused on receiving multiple AMQP frames at the same time. - - Add auto-coercion of property data-types. -- 0.10.0 - released *2013-12-11* - - Rewrite of IO layer yielding improved performance and reduction of CPU usage, bugfixes -- 0.9.0 - released *2013-10-02* - - Major performance improvements, CPU usage reduction, minor bug-fixes -- 0.8.0 - released *2013-10-01* - - Major bugfixes - - IPv6 support -- 0.7.0 - released *2013-10-01* - - Bugfixes and code cleanup. - - Most notable fix around Basic.Return and recursion in Channel._wait_on_frame. -- 0.6.0 - released *2013-09-30* - - Bugfix with Queue.get() - - Bugfix with RPC requests expecting multiple responses - - Add Queue.consume_messages() method. -- 0.5.1 - released *2013-09-24* - - Installer/setup fix -- 0.5.0 - released *2013-09-23* - - Bugfix release including low level socket sending fix and connection timeouts. -- < 0.5.0 - - Previously called rmqid diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..22eea76 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,43 @@ +# rabbitpy + +rabbitpy is a pure python, thread-safe, and pythonic BSD-licensed AMQP/RabbitMQ +client library. It aims to provide a simple and easy to use API for interfacing +with RabbitMQ, minimizing the programming overhead often found in other libraries. + +## Installation + +```bash +pip install rabbitpy +``` + +## Quick Start + +### Connect and publish a message + +```python +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + message = rabbitpy.Message(channel, 'Hello, RabbitMQ!') + message.publish('my-exchange', 'routing-key') +``` + +### Consume messages + +```python +import rabbitpy + +with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: + with conn.channel() as channel: + queue = rabbitpy.Queue(channel, 'my-queue') + for message in queue: + print(message.body) + message.ack() +``` + +## Links + +- [GitHub](https://github.com/gmr/rabbitpy) +- [PyPI](https://pypi.org/project/rabbitpy/) +- [Issue Tracker](https://github.com/gmr/rabbitpy/issues) diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index e0277eb..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,75 +0,0 @@ -.. rabbitpy documentation master file, created by - sphinx-quickstart on Wed Mar 27 18:31:37 2013. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -rabbitpy: RabbitMQ Simplified -============================= -rabbitpy is a pure python, thread-safe [#]_, and pythonic BSD Licensed AMQP/RabbitMQ -library that supports Python 2.7+ and 3.4+. rabbitpy aims to provide a simple -and easy to use API for interfacing with RabbitMQ, minimizing the programming -overhead often found in other libraries. - -|Version| - -Installation ------------- -rabbitpy is available from the `Python Package Index `_ and can be -installed by running :command:`easy_install rabbitpy` or :command:`pip install rabbitpy` - -API Documentation ------------------ -rabbitpy is designed to have as simple and pythonic of an API as possible while -still remaining true to RabbitMQ and to the AMQP 0-9-1 specification. There are -two basic ways to interact with rabbitpy, using the simple wrapper methods: - -.. toctree:: - :glob: - :maxdepth: 2 - - simple - -And by using the core objects: - -.. toctree:: - :glob: - :maxdepth: 1 - - api/* - -.. [#] If you're looking to use rabbitpy in a multi-threaded application, you should the notes about multi-threaded use in :doc:`threads`. - -Examples --------- -.. toctree:: - :maxdepth: 2 - :glob: - - examples/* - -Issues ------- -Please report any issues to the Github repo at `https://github.com/gmr/rabbitpy/issues `_ - -Source ------- -rabbitpy source is available on Github at `https://github.com/gmr/rabbitpy `_ - -Version History ---------------- -See :doc:`history` - -Inspiration ------------ -rabbitpy's simple and more pythonic interface is inspired by `Kenneth Reitz's `_ awesome work on `requests `_. - -Indices and tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - - -.. |Version| image:: https://badge.fury.io/py/rabbitpy.svg? - :target: http://badge.fury.io/py/rabbitpy diff --git a/docs/simple.rst b/docs/simple.rst deleted file mode 100644 index 1dac31b..0000000 --- a/docs/simple.rst +++ /dev/null @@ -1,16 +0,0 @@ -Simple API Methods -================== - -rabbitpy's simple API methods are meant for one off use, either in your apps or in -the python interpreter. For example, if your application publishes a single -message as part of its lifetime, :py:meth:`rabbitpy.publish` should be enough -for almost any publishing concern. However if you are publishing more than -one message, it is not an efficient method to use as it connects and disconnects -from RabbitMQ on each invocation. :py:meth:`rabbitpy.get` also connects and -disconnects on each invocation. :py:meth:`rabbitpy.consume` does stay connected -as long as you're iterating through the messages returned by it. Exiting the -generator will close the connection. For a more complete api, see the rabbitpy -core API. - -.. automodule:: rabbitpy.simple - :members: diff --git a/docs/threads.md b/docs/threads.md new file mode 100644 index 0000000..60d5b31 --- /dev/null +++ b/docs/threads.md @@ -0,0 +1,68 @@ +# Multi-threaded Use + +To ensure that the network communication module at the core of rabbitpy is +thread-safe, the `rabbitpy.io.IO` class is a daemonic Python thread that uses +a combination of `threading.Event`, `queue.Queue`, and a local cross-platform +implementation of a read-write socket pair. + +While ensuring that the core socket I/O and dispatching of AMQP frames across +threads is safe, it does not protect against cross-thread channel utilization. + +Due to the way channel events are managed, **restrict each channel to a single +thread**. Sharing channels across threads can create issues with channel state +in the AMQP protocol. + +## Example + +The following example uses the main thread to connect to RabbitMQ and then +spawns separate threads for publishing and consuming: + +```python +import rabbitpy +import threading + +EXCHANGE = 'threading_example' +QUEUE = 'threading_queue' +ROUTING_KEY = 'test' +MESSAGE_COUNT = 100 + + +def consumer(connection): + """Consume MESSAGE_COUNT messages then exit.""" + received = 0 + with connection.channel() as channel: + for message in rabbitpy.Queue(channel, QUEUE): + print(message.body) + message.ack() + received += 1 + if received == MESSAGE_COUNT: + break + + +def publisher(connection): + """Publish MESSAGE_COUNT messages.""" + with connection.channel() as channel: + for index in range(MESSAGE_COUNT): + message = rabbitpy.Message(channel, f'Message #{index}') + message.publish(EXCHANGE, ROUTING_KEY) + + +with rabbitpy.Connection() as connection: + with connection.channel() as channel: + exchange = rabbitpy.Exchange(channel, EXCHANGE) + exchange.declare() + + queue = rabbitpy.Queue(channel, QUEUE) + queue.declare() + queue.bind(EXCHANGE, ROUTING_KEY) + + kwargs = {'connection': connection} + + consumer_thread = threading.Thread(target=consumer, kwargs=kwargs) + consumer_thread.start() + + publisher_thread = threading.Thread(target=publisher, kwargs=kwargs) + publisher_thread.start() + + consumer_thread.join() +``` diff --git a/docs/threads.rst b/docs/threads.rst deleted file mode 100644 index 47b98b5..0000000 --- a/docs/threads.rst +++ /dev/null @@ -1,95 +0,0 @@ -Multi-threaded Use Notes -======================== -To ensure that the network communication module at the core of rabbitpy is -thread safe, the :py:class:`rabbitpy.io.IO` class is a daemonic Python thread -that uses a combination of :py:class:`threading.Event`, :py:class:`Queue.Queue`, -and a local cross-platform implementation of a read-write socket pair in -:py:attr:`rabbitpy.IO.write_trigger`. - -While ensuring that the core socket IO and dispatching of AMQP frames across -threads goes a long way to make sure that multi-threaded applications can safely -use rabbitpy, it does not protect against cross-thread channel utilization. - -Due to the way that channels events are managed, it is recommend that you restrict -the use of a channel to an individual thread. By not sharing channels across -threads, you will ensure that you do not accidentally create issues with -channel state in the AMQP protocol. As an asynchronous RPC style protocol, when -you issue commands, such as a queue declaration, or are publishing a message, -there are expectations in the conversation on a channel about the order of -events and frames sent and received. - -The following example uses the main Python thread to connect to RabbitMQ and -then spawns a thread for publishing and a thread for consuming. - -.. code :: python - - import rabbitpy - import threading - - EXCHANGE = 'threading_example' - QUEUE = 'threading_queue' - ROUTING_KEY = 'test' - MESSAGE_COUNT = 100 - - - def consumer(connection): - """Consume MESSAGE_COUNT messages on the connection and then exit. - - :param rabbitpy.Connection connection: The connection to consume on - - """ - received = 0 - with connection.channel() as channel: - for message in rabbitpy.Queue(channel, QUEUE).consume_messages(): - print message.body - message.ack() - received += 1 - if received == MESSAGE_COUNT: - break - - - def publisher(connection): - """Pubilsh up to MESSAGE_COUNT messages on connection - on an individual thread. - - :param rabbitpy.Connection connection: The connection to publish on - - """ - with connection.channel() as channel: - for index in range(0, MESSAGE_COUNT): - message = rabbitpy.Message(channel, 'Message #%i' % index) - message.publish(EXCHANGE, ROUTING_KEY) - - - # Connect to RabbitMQ - with rabbitpy.Connection() as connection: - - # Open the channel, declare and bind the exchange and queue - with connection.channel() as channel: - - # Declare the exchange - exchange = rabbitpy.Exchange(channel, EXCHANGE) - exchange.declare() - - # Declare the queue - queue = rabbitpy.Queue(channel, QUEUE) - queue.declare() - - # Bind the queue to the exchange - queue.bind(EXCHANGE, ROUTING_KEY) - - - # Pass in the kwargs - kwargs = {'connection': connection} - - # Start the consumer thread - consumer_thread = threading.Thread(target=consumer, kwargs=kwargs) - consumer_thread.start() - - # Start the pubisher thread - publisher_thread = threading.Thread(target=publisher, kwargs=kwargs) - publisher_thread.start() - - # Join the consumer thread, waiting for it to consume all MESSAGE_COUNT messages - consumer_thread.join() - diff --git a/examples/consumer.py b/examples/consumer.py index abe0022..b20c136 100644 --- a/examples/consumer.py +++ b/examples/consumer.py @@ -1,5 +1,6 @@ #!/usr/bin/env python import logging + import rabbitpy URL = 'amqp://guest:guest@localhost:5672/%2f?heartbeat=15' diff --git a/examples/getter.py b/examples/getter.py index a537efc..19b82bc 100644 --- a/examples/getter.py +++ b/examples/getter.py @@ -8,4 +8,4 @@ message = queue.get() message.pprint(True) message.ack() - print('There are {} more messages in the queue'.format(len(queue))) \ No newline at end of file + print(f'There are {len(queue)} more messages in the queue') diff --git a/examples/publisher.py b/examples/publisher.py index b99a19c..43c6fdc 100644 --- a/examples/publisher.py +++ b/examples/publisher.py @@ -1,16 +1,16 @@ #!/usr/bin/env python -import rabbitpy -import logging import datetime +import logging import uuid + +import rabbitpy + logging.basicConfig(level=logging.DEBUG) # Use a new connection as a context manager with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: - # Use the channel as a context manager with conn.channel() as channel: - # Create the exchange exchange = rabbitpy.Exchange(channel, 'example_exchange') exchange.declare() @@ -23,14 +23,17 @@ queue.bind(exchange, 'test-routing-key') # Create the msg by passing channel, message and properties (as a dict) - message = rabbitpy.Message(channel, - 'Lorem ipsum dolor sit amet, consectetur ' - 'adipiscing elit.', - {'content_type': 'text/plain', - 'delivery_mode': 1, - 'message_type': 'Lorem ipsum', - 'timestamp': datetime.datetime.now(), - 'message_id': uuid.uuid1()}) + message = rabbitpy.Message( + channel, + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + { + 'content_type': 'text/plain', + 'delivery_mode': 1, + 'message_type': 'Lorem ipsum', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'message_id': uuid.uuid1(), + }, + ) # Publish the message message.publish(exchange, 'test-routing-key') diff --git a/examples/transactional_publisher.py b/examples/transactional_publisher.py index 9cfa0eb..ad6640e 100644 --- a/examples/transactional_publisher.py +++ b/examples/transactional_publisher.py @@ -1,11 +1,11 @@ #!/usr/bin/env python -import rabbitpy import datetime import uuid +import rabbitpy + with rabbitpy.Connection('amqp://guest:guest@localhost:5672/%2f') as conn: with conn.channel() as channel: - # Create the exchange exchange = rabbitpy.Exchange(channel, 'example_exchange') exchange.declare() @@ -22,14 +22,16 @@ tx.select() # Create the message - message = rabbitpy.Message(channel, - 'Lorem ipsum dolor sit amet, consectetur ' - 'adipiscing elit.', - {'content_type': 'text/plain', - 'message_type': 'Lorem ipsum', - 'timestamp': datetime.datetime.now(), - 'message_id': uuid.uuid1() - }) + message = rabbitpy.Message( + channel, + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + { + 'content_type': 'text/plain', + 'message_type': 'Lorem ipsum', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'message_id': uuid.uuid1(), + }, + ) # Publish the message message.publish(exchange, 'test-routing-key') diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..329483a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,56 @@ +site_name: rabbitpy +site_description: A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library +site_url: https://gmr.github.io/rabbitpy/ +repo_url: https://github.com/gmr/rabbitpy +repo_name: gmr/rabbitpy + +theme: + name: material + palette: + - scheme: default + primary: deep orange + accent: orange + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: deep orange + accent: orange + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - content.code.copy + - navigation.footer + - navigation.sections + - navigation.top + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + show_source: false + show_root_heading: true + heading_level: 2 + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.snippets + +nav: + - Home: index.md + - API Reference: + - Connection: api/connection.md + - Exceptions: api/exceptions.md + - Examples: + - Consumer: examples/consumer.md + - Publisher: examples/publisher.md + - Message Getter: examples/getter.md + - Threading Notes: threads.md + - Version History: history.md diff --git a/pyproject.toml b/pyproject.toml index 7ca5712..91a064d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,64 +1,198 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "rabbitpy" -version = "2.0.1" +version = "3.0.0" description = "A pure python, thread-safe, minimalistic and pythonic RabbitMQ client library" readme = "README.rst" -requires-python = ">=3.8" -license = { file = "LICENSE" } -authors = [ { name = "Gavin M. Roy", email = "gavinmroy@gmail.com" } ] +requires-python = ">=3.11" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +authors = [ + {name = "Gavin M. Roy", email = "gavinmroy@gmail.com"} +] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", + "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Communications", "Topic :: Internet", - "Topic :: Software Development :: Libraries" -] -dependencies = [ - "pamqp>=2.3.0,<3.0", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", ] +keywords = ["rabbitmq", "amqp", "messaging"] +dependencies = ["pamqp>=4.0,<5.0"] -[project.optional-dependencies] +[project.urls] +Homepage = "https://github.com/gmr/rabbitpy" +Documentation = "https://rabbitpy.readthedocs.io" + +[dependency-groups] dev = [ - "codecov", + "basedpyright", + "build", "coverage[toml]", - "mock", - "flake8", - "flake8-comprehensions", - "flake8-deprecated", - "flake8-html", - "flake8-import-order", - "flake8-pyproject", - "flake8-quotes", - "flake8-rst-docstrings", - "flake8-tuple", - "codeclimate-test-reporter", + "mypy", + "pre-commit", + "python-dotenv", + "ruff", +] +docs = [ + "mkdocs>=1.5,<2", + "mkdocs-material>=9.5,<10", + "mkdocstrings[python]", ] -[project.urls] -Documentation = "https://rabbitpy.readthedocs.io" -Repository = "https://github.com/gmr/rabbitpy.git" -"Code Coverage" = "https://app.codecov.io/github/gmr/rabbitpy" +[tool.coverage.run] +branch = true +command_line = "-m unittest discover tests --buffer --verbose" +data_file = "build/.coverage" +source = ["rabbitpy"] -[tool.flake8] -application-import-names = ["rabbitpy"] -exclude = ["build", "ci", "docs", "env"] -ignore = "RST304" -import-order-style = "google" +[tool.coverage.report] +show_missing = true +exclude_also = [ + "typing.TYPE_CHECKING", +] + +[tool.coverage.html] +directory = "build/coverage" [tool.coverage.xml] output = "build/coverage.xml" -[tool.coverage.run] -branch = true -source = ["rabbitpy"] -command_line = "-m unittest discover tests --buffer --verbose" +[tool.hatch.build.targets.wheel] +include = ["rabbitpy", "rabbitpy/py.typed"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false +disallow_untyped_calls = false +check_untyped_defs = true + +[[tool.mypy.overrides]] +# Legacy test files: test type-validation (intentionally wrong types), +# use pre-rewrite channel API, or have Python 2 compat cruft +module = [ + "tests.integration_tests", + "tests.utils_tests", + "tests.base_tests", + "tests.channel_test", + "tests.helpers", + "tests.test_queue", + "tests.test_message", +] +ignore_errors = true + +[[tool.mypy.overrides]] +# Restored legacy modules — not yet fully typed; suppress strict errors +module = [ + "rabbitpy.amqp", + "rabbitpy.amqp_queue", + "rabbitpy.base", + "rabbitpy.channel", + "rabbitpy.channel0", + "rabbitpy.exchange", + "rabbitpy.heartbeat", + "rabbitpy.message", + "rabbitpy.simple", + "rabbitpy.tx", + "rabbitpy.utils", +] +disallow_untyped_defs = false +disallow_untyped_calls = false +ignore_errors = true + +[tool.basedpyright] +pythonVersion = "3.11" +typeCheckingMode = "standard" +include = ["rabbitpy", "tests"] +exclude = [ + "examples/**", + ".venv/**", + "tests/base_tests.py", + "tests/channel_test.py", + "tests/helpers.py", + "tests/integration_tests.py", + "tests/test_message.py", + "tests/test_queue.py", + "tests/utils_tests.py", + "rabbitpy/amqp.py", + "rabbitpy/amqp_queue.py", + "rabbitpy/base.py", + "rabbitpy/channel.py", + "rabbitpy/channel0.py", + "rabbitpy/exchange.py", + "rabbitpy/heartbeat.py", + "rabbitpy/message.py", + "rabbitpy/simple.py", + "rabbitpy/tx.py", + "rabbitpy/utils.py", +] + +[tool.ruff] +line-length = 79 +target-version = "py311" + +[tool.ruff.format] +quote-style = "single" + +[tool.ruff.lint] +select = [ + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "C90", # mccabe + "E", "W", # pycodestyle + "F", # pyflakes + "G", # flake8-logging-format + "I", # isort + "N", # pep8-naming + "Q", # flake8-quotes + "S", # flake8-bandit + "ASYNC", # flake8-async + "B", # flake8-bugbear + "DTZ", # flake8-datetimez + "FURB", # refurb + "RUF", # ruff-specific + "T20", # flake8-print + "UP", # pyupgrade +] +ignore = [ + "N818", # exception class names not required to end in Error + "RSE", # contradicts Python Style Guide + "UP040", # allow non-PEP695 type alias syntax +] +flake8-quotes = {inline-quotes = "single"} + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["E501", "N802", "RUF012", "S", "T20", "T203"] +"examples/**/*.py" = ["S", "T20"] +# Restored legacy modules — relax line-length and mutable-default rules +"rabbitpy/base.py" = ["E501", "RUF012", "UP007"] +"rabbitpy/channel.py" = ["E501", "RUF012"] +"rabbitpy/channel0.py" = ["E501", "RUF012"] +"rabbitpy/amqp_queue.py" = ["E501", "RUF002", "RUF012"] +"rabbitpy/exchange.py" = ["E501", "RUF012"] +"rabbitpy/message.py" = ["E501", "RUF012"] +"rabbitpy/simple.py" = ["E501", "RUF012"] +"rabbitpy/tx.py" = ["E501", "RUF012"] +"rabbitpy/heartbeat.py" = ["E501", "RUF012"] +"rabbitpy/amqp.py" = ["E501", "RUF012"] diff --git a/rabbitpy/__init__.py b/rabbitpy/__init__.py index 9ff243d..b9ebd55 100644 --- a/rabbitpy/__init__.py +++ b/rabbitpy/__init__.py @@ -2,66 +2,62 @@ rabbitpy, a pythonic RabbitMQ client """ -__version__ = '2.0.1' + +__version__ = '3.0.0' import logging from rabbitpy.amqp import AMQP -from rabbitpy.connection import Connection +from rabbitpy.amqp_queue import Queue from rabbitpy.channel import Channel -from rabbitpy.exchange import Exchange -from rabbitpy.exchange import DirectExchange -from rabbitpy.exchange import FanoutExchange -from rabbitpy.exchange import HeadersExchange -from rabbitpy.exchange import TopicExchange +from rabbitpy.connection import Connection +from rabbitpy.exchange import ( + DirectExchange, + Exchange, + FanoutExchange, + HeadersExchange, + TopicExchange, +) from rabbitpy.message import Message -from rabbitpy.amqp_queue import Queue +from rabbitpy.simple import ( + SimpleChannel, + consume, + create_direct_exchange, + create_fanout_exchange, + create_headers_exchange, + create_queue, + create_topic_exchange, + delete_exchange, + delete_queue, + get, + publish, +) from rabbitpy.tx import Tx -from rabbitpy.simple import SimpleChannel -from rabbitpy.simple import consume -from rabbitpy.simple import get -from rabbitpy.simple import publish -from rabbitpy.simple import create_queue -from rabbitpy.simple import delete_queue -from rabbitpy.simple import create_direct_exchange -from rabbitpy.simple import create_fanout_exchange -from rabbitpy.simple import create_headers_exchange -from rabbitpy.simple import create_topic_exchange -from rabbitpy.simple import delete_exchange - logging.getLogger('rabbitpy').addHandler(logging.NullHandler()) __all__ = [ - '__version__', - 'amqp_queue', - 'channel', - 'connection', - 'exceptions', - 'exchange', - 'message', - 'simple', - 'tx', 'AMQP', - 'Connection', 'Channel', - 'SimpleChannel', - 'Exchange', + 'Connection', 'DirectExchange', + 'Exchange', 'FanoutExchange', 'HeadersExchange', - 'TopicExchange', 'Message', 'Queue', + 'SimpleChannel', + 'TopicExchange', 'Tx', + '__version__', 'consume', - 'get', - 'publish', - 'create_queue', - 'delete_queue', 'create_direct_exchange', 'create_fanout_exchange', 'create_headers_exchange', + 'create_queue', 'create_topic_exchange', - 'delete_exchange' + 'delete_exchange', + 'delete_queue', + 'get', + 'publish', ] diff --git a/rabbitpy/amqp.py b/rabbitpy/amqp.py index dde40e9..dc175d9 100644 --- a/rabbitpy/amqp.py +++ b/rabbitpy/amqp.py @@ -2,12 +2,12 @@ AMQP Adapter """ -from pamqp import specification as spec -from rabbitpy import base -from rabbitpy import message -from rabbitpy import exceptions -from rabbitpy import utils +import typing + +from pamqp import commands + +from rabbitpy import base, channel, exceptions, message, utils # pylint: disable=too-many-public-methods @@ -15,15 +15,16 @@ class AMQP(base.ChannelWriter): """The AMQP Adapter provides a more generic, non-opinionated interface to RabbitMQ by providing methods that map to the AMQP API. - :param rabbitmq.channel.Channel channel: The channel to use + :param rabbitpy_channel: The channel to use """ - def __init__(self, channel): - super(AMQP, self).__init__(channel) - self.consumer_tag = 'rabbitpy.%s.%s' % (self.channel.id, id(self)) + + def __init__(self, rabbitpy_channel: channel.Channel): + super().__init__(rabbitpy_channel) + self.consumer_tag = f'rabbitpy.{self.channel.id}.{id(self)}' self._consuming = False - def basic_ack(self, delivery_tag=0, multiple=False): + def basic_ack(self, delivery_tag: int = 0, multiple: bool = False) -> None: """Acknowledge one or more messages This method acknowledges one or more messages delivered via the Deliver @@ -31,22 +32,28 @@ def basic_ack(self, delivery_tag=0, multiple=False): set of messages up to and including a specific message. :param delivery_tag: Server-assigned delivery tag - :type delivery_tag: int|long - :param bool multiple: Acknowledge multiple messages + :param multiple: Acknowledge multiple messages """ - self._write_frame(spec.Basic.Ack(delivery_tag, multiple)) - - def basic_consume(self, queue='', consumer_tag='', no_local=False, - no_ack=False, exclusive=False, nowait=False, - arguments=None): + self._write_frame(commands.Basic.Ack(delivery_tag, multiple)) + + def basic_consume( + self, + queue: str = '', + consumer_tag: str = '', + no_local: bool = False, + no_ack: bool = False, + exclusive: bool = False, + nowait: bool = False, + arguments: dict | None = None, + ) -> typing.Generator[message.Message, None, None]: """Start a queue consumer This method asks the server to start a "consumer", which is a transient request for messages from a specific queue. Consumers last as long as the channel they were declared on, or until the client cancels them. - This method will act as an generator, returning messages as they are + This method will act as a generator, returning messages as they are delivered from the server. Example use: @@ -54,29 +61,37 @@ def basic_consume(self, queue='', consumer_tag='', no_local=False, .. code:: python for message in basic_consume(queue_name): - print message.body + print(message.body) message.ack() - :param str queue: The queue name to consume from - :param str consumer_tag: The consumer tag - :param bool no_local: Do not deliver own messages - :param bool no_ack: No acknowledgement needed - :param bool exclusive: Request exclusive access - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for declaration + :param queue: The queue name to consume from + :param consumer_tag: The consumer tag + :param no_local: Do not deliver own messages + :param no_ack: No acknowledgement needed + :param exclusive: Request exclusive access + :param nowait: Do not send a reply method + :param arguments: Arguments for declaration """ if not consumer_tag: consumer_tag = self.consumer_tag - # pylint: disable=protected-access - self.channel._consumers[consumer_tag] = (self, no_ack) - self._rpc(spec.Basic.Consume(0, queue, consumer_tag, no_local, no_ack, - exclusive, nowait, arguments)) + self.channel.consumers[consumer_tag] = (self, no_ack) + self._rpc( + commands.Basic.Consume( + 0, + queue, + consumer_tag, + no_local, + no_ack, + exclusive, + nowait, + arguments, + ) + ) self._consuming = True try: while self._consuming: - # pylint: disable=protected-access - msg = self.channel._consume_message() + msg = self.channel.consume_message() if msg: yield msg else: @@ -87,41 +102,49 @@ def basic_consume(self, queue='', consumer_tag='', no_local=False, if self._consuming: self.basic_cancel(consumer_tag) - def basic_cancel(self, consumer_tag='', nowait=False): + def basic_cancel( + self, consumer_tag: str = '', nowait: bool = False + ) -> None: """End a queue consumer This method cancels a consumer. This does not affect already delivered messages, but it does mean the server will not send any more messages for that consumer. The client may receive an arbitrary number of - messages in between sending the cancel method and receiving the cancel- - ok reply. + messages in between sending the cancel method and receiving the + cancel-ok reply. - :param str consumer_tag: Consumer tag - :param bool nowait: Do not send a reply method + :param consumer_tag: Consumer tag + :param nowait: Do not send a reply method """ if utils.PYPY and not self._consuming: return if not self._consuming: raise exceptions.NotConsumingError() - # pylint: disable=protected-access - self.channel._cancel_consumer(self, consumer_tag, nowait) + self.channel.cancel_consumer(self, consumer_tag, nowait) self._consuming = False - def basic_get(self, queue='', no_ack=False): + def basic_get( + self, queue: str = '', no_ack: bool = False + ) -> 'message.Message | None': """Direct access to a queue - This method provides a direct access to the messages in a queue using a + This method provides direct access to the messages in a queue using a synchronous dialogue that is designed for specific types of application where synchronous functionality is more important than performance. - :param str queue: The queue name - :param bool no_ack: No acknowledgement needed + :param queue: The queue name + :param no_ack: No acknowledgement needed """ - self._rpc(spec.Basic.Get(0, queue, no_ack)) - - def basic_nack(self, delivery_tag=0, multiple=False, requeue=True): + return self._rpc(commands.Basic.Get(0, queue, no_ack)) + + def basic_nack( + self, + delivery_tag: int = 0, + multiple: bool = False, + requeue: bool = True, + ) -> None: """Reject one or more incoming messages. This method allows a client to reject one or more incoming messages. It @@ -132,15 +155,21 @@ def basic_nack(self, delivery_tag=0, multiple=False, requeue=True): needs to republish the offending messages. :param delivery_tag: Server-assigned delivery tag - :type delivery_tag: int|long - :param bool multiple: Reject multiple messages - :param bool requeue: Requeue the message + :param multiple: Reject multiple messages + :param requeue: Requeue the message """ - self._write_frame(spec.Basic.Nack(delivery_tag, multiple, requeue)) - - def basic_publish(self, exchange='', routing_key='', body='', - properties=None, mandatory=False, immediate=False): + self._write_frame(commands.Basic.Nack(delivery_tag, multiple, requeue)) + + def basic_publish( + self, + exchange: str = '', + routing_key: str = '', + body: str = '', + properties: dict | bytes | None = None, + mandatory: bool = False, + immediate: bool = False, + ) -> bool | None: """Publish a message This method publishes a message to a specific exchange. The message @@ -148,21 +177,23 @@ def basic_publish(self, exchange='', routing_key='', body='', distributed to any active consumers when the transaction, if any, is committed. - :param str exchange: The exchange name - :param str routing_key: Message routing key + :param exchange: The exchange name + :param routing_key: Message routing key :param body: The message body - :type body: str|bytes - :param dict properties: AMQP message properties - :param bool mandatory: Indicate mandatory routing - :param bool immediate: Request immediate delivery - :return: bool or None + :param properties: AMQP message properties + :param mandatory: Indicate mandatory routing + :param immediate: Request immediate delivery """ - msg = message.Message(self.channel, body, - properties or {}, False, False) + msg = message.Message(self.channel, body, properties or {}, False) return msg.publish(exchange, routing_key, mandatory, immediate) - def basic_qos(self, prefetch_size=0, prefetch_count=0, global_flag=False): + def basic_qos( + self, + prefetch_size: int = 0, + prefetch_count: int = 0, + global_flag: bool = False, + ) -> None: """Specify quality of service This method requests a specific quality of service. The QoS can be @@ -173,14 +204,17 @@ def basic_qos(self, prefetch_size=0, prefetch_count=0, global_flag=False): for the server. :param prefetch_size: Prefetch window in octets - :type prefetch_size: int|long - :param int prefetch_count: Prefetch window in messages - :param bool global_flag: Apply to entire connection + :param prefetch_count: Prefetch window in messages + :param global_flag: Apply to entire connection """ - self._rpc(spec.Basic.Qos(prefetch_size, prefetch_count, global_flag)) + self._rpc( + commands.Basic.Qos(prefetch_size, prefetch_count, global_flag) + ) - def basic_reject(self, delivery_tag=0, requeue=True): + def basic_reject( + self, delivery_tag: int = 0, requeue: bool = True + ) -> None: """Reject an incoming message This method allows a client to reject a message. It can be used to @@ -188,102 +222,144 @@ def basic_reject(self, delivery_tag=0, requeue=True): messages to their original queue. :param delivery_tag: Server-assigned delivery tag - :type delivery_tag: int|long - :param bool requeue: Requeue the message + :param requeue: Requeue the message """ - self._write_frame(spec.Basic.Reject(delivery_tag, requeue)) + self._write_frame(commands.Basic.Reject(delivery_tag, requeue)) - def basic_recover(self, requeue=False): + def basic_recover(self, requeue: bool = False) -> None: """Redeliver unacknowledged messages This method asks the server to redeliver all unacknowledged messages on a specified channel. Zero or more messages may be redelivered. This method replaces the asynchronous Recover. - :param bool requeue: Requeue the message + :param requeue: Requeue the message """ - self._rpc(spec.Basic.Recover(requeue)) + self._rpc(commands.Basic.Recover(requeue)) - def confirm_select(self): + def confirm_select(self) -> None: """This method sets the channel to use publisher acknowledgements. The client can only use this method on a non-transactional channel. """ - self._rpc(spec.Confirm.Select()) - - def exchange_declare(self, exchange='', exchange_type='direct', - passive=False, durable=False, auto_delete=False, - internal=False, nowait=False, arguments=None): + self._rpc(commands.Confirm.Select()) + + def exchange_declare( + self, + exchange: str = '', + exchange_type: str = 'direct', + passive: bool = False, + durable: bool = False, + auto_delete: bool = False, + internal: bool = False, + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Verify exchange exists, create if needed This method creates an exchange if it does not already exist, and if the exchange exists, verifies that it is of the correct and expected class. - :param str exchange: The exchange name - :param str exchange_type: Exchange type - :param bool passive: Do not create exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param bool internal: Deprecated - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for declaration + :param exchange: The exchange name + :param exchange_type: Exchange type + :param passive: Do not create exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param internal: Deprecated + :param nowait: Do not send a reply method + :param arguments: Arguments for declaration """ - self._rpc(spec.Exchange.Declare(0, exchange, exchange_type, passive, - durable, auto_delete, internal, nowait, - arguments)) - - def exchange_delete(self, exchange='', if_unused=False, - nowait=False): + self._rpc( + commands.Exchange.Declare( + 0, + exchange, + exchange_type, + passive, + durable, + auto_delete, + internal, + nowait, + arguments, + ) + ) + + def exchange_delete( + self, exchange: str = '', if_unused: bool = False, nowait: bool = False + ) -> None: """Delete an exchange This method deletes an exchange. When an exchange is deleted all queue bindings on the exchange are cancelled. - :param str exchange: The exchange name - :param bool if_unused: Delete only if unused - :param bool nowait: Do not send a reply method + :param exchange: The exchange name + :param if_unused: Delete only if unused + :param nowait: Do not send a reply method """ - self._rpc(spec.Exchange.Delete(0, exchange, if_unused, nowait)) - - def exchange_bind(self, destination='', source='', - routing_key='', nowait=False, arguments=None): + self._rpc(commands.Exchange.Delete(0, exchange, if_unused, nowait)) + + def exchange_bind( + self, + destination: str = '', + source: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Bind exchange to an exchange. This method binds an exchange to an exchange. - :param str destination: The destination exchange name - :param str source: The source exchange name - :param str routing_key: The routing key to bind with - :param bool nowait: Do not send a reply method - :param dict arguments: Optional arguments + :param destination: The destination exchange name + :param source: The source exchange name + :param routing_key: The routing key to bind with + :param nowait: Do not send a reply method + :param arguments: Optional arguments """ - self._rpc(spec.Exchange.Bind(0, destination, source, routing_key, - nowait, arguments)) - - def exchange_unbind(self, destination='', source='', - routing_key='', nowait=False, arguments=None): + self._rpc( + commands.Exchange.Bind( + 0, destination, source, routing_key, nowait, arguments + ) + ) + + def exchange_unbind( + self, + destination: str = '', + source: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Unbind an exchange from an exchange. This method unbinds an exchange from an exchange. - :param str destination: The destination exchange name - :param str source: The source exchange name - :param str routing_key: The routing key to bind with - :param bool nowait: Do not send a reply method - :param dict arguments: Optional arguments + :param destination: The destination exchange name + :param source: The source exchange name + :param routing_key: The routing key to bind with + :param nowait: Do not send a reply method + :param arguments: Optional arguments """ - self._rpc(spec.Exchange.Unbind(0, destination, source, routing_key, - nowait, arguments)) - - def queue_bind(self, queue='', exchange='', routing_key='', - nowait=False, arguments=None): + self._rpc( + commands.Exchange.Unbind( + 0, destination, source, routing_key, nowait, arguments + ) + ) + + def queue_bind( + self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Bind queue to an exchange This method binds a queue to an exchange. Until a queue is bound it @@ -291,81 +367,112 @@ def queue_bind(self, queue='', exchange='', routing_key='', forward queues are bound to a direct exchange and subscription queues are bound to a topic exchange. - :param str queue: The queue name - :param str exchange: Name of the exchange to bind to - :param str routing_key: Message routing key - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for binding + :param queue: The queue name + :param exchange: The name of the exchange to bind to + :param routing_key: Message routing key + :param nowait: Do not send a reply method + :param arguments: Arguments for binding """ - self._rpc(spec.Queue.Bind(0, queue, exchange, routing_key, nowait, - arguments)) - - def queue_declare(self, queue='', passive=False, durable=False, - exclusive=False, auto_delete=False, nowait=False, - arguments=None): + self._rpc( + commands.Queue.Bind( + 0, queue, exchange, routing_key, nowait, arguments + ) + ) + + def queue_declare( + self, + queue: str = '', + passive: bool = False, + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + nowait: bool = False, + arguments: dict | None = None, + ) -> None: """Declare queue, create if needed This method creates or checks a queue. When creating a new queue the - client can specify various properties that control the durability of + client can specific various properties that control the durability of the queue and its contents, and the level of sharing for the queue. - :param str queue: The queue name - :param bool passive: Do not create queue - :param bool durable: Request a durable queue - :param bool exclusive: Request an exclusive queue - :param bool auto_delete: Auto-delete queue when unused - :param bool nowait: Do not send a reply method - :param dict arguments: Arguments for declaration + :param queue: The queue name + :param passive: Do not create queue + :param durable: Request a durable queue + :param exclusive: Request an exclusive queue + :param auto_delete: Auto-delete queue when unused + :param nowait: Do not send a reply method + :param arguments: Arguments for declaration """ - self._rpc(spec.Queue.Declare(0, queue, passive, durable, exclusive, - auto_delete, nowait, arguments)) - - def queue_delete(self, queue='', if_unused=False, if_empty=False, - nowait=False): + self._rpc( + commands.Queue.Declare( + 0, + queue, + passive, + durable, + exclusive, + auto_delete, + nowait, + arguments, + ) + ) + + def queue_delete( + self, + queue: str = '', + if_unused: bool = False, + if_empty: bool = False, + nowait: bool = False, + ) -> None: """Delete a queue This method deletes a queue. When a queue is deleted any pending messages are sent to a dead-letter queue if this is defined in the server configuration, and all consumers on the queue are cancelled. - :param str queue: The queue name - :param bool if_unused: Delete only if unused - :param bool if_empty: Delete only if empty - :param bool nowait: Do not send a reply method + :param queue: The queue name + :param if_unused: Delete only if unused + :param if_empty: Delete only if empty + :param nowait: Do not send a reply method """ - self._rpc(spec.Queue.Delete(0, queue, if_unused, if_empty, nowait)) + self._rpc(commands.Queue.Delete(0, queue, if_unused, if_empty, nowait)) - def queue_purge(self, queue='', nowait=False): + def queue_purge(self, queue: str = '', nowait: bool = False) -> None: """Purge a queue This method removes all messages from a queue which are not awaiting acknowledgment. - :param str queue: The queue name - :param bool nowait: Do not send a reply method + :param queue: The queue name + :param nowait: Do not send a reply method """ - self._rpc(spec.Queue.Purge(0, queue, nowait)) - - def queue_unbind(self, queue='', exchange='', routing_key='', - arguments=None): + self._rpc(commands.Queue.Purge(0, queue, nowait)) + + def queue_unbind( + self, + queue: str = '', + exchange: str = '', + routing_key: str = '', + arguments: dict | None = None, + ) -> None: """Unbind a queue from an exchange This method unbinds a queue from an exchange. - :param str queue: The queue name - :param str exchange: The exchange name - :param str routing_key: Routing key of binding - :param dict arguments: Arguments of binding + :param queue: The queue name + :param exchange: The exchange name + :param routing_key: Routing key of binding + :param arguments: Arguments of binding """ - self._rpc(spec.Queue.Unbind(0, queue, exchange, routing_key, - arguments)) + self._rpc( + commands.Queue.Unbind(0, queue, exchange, routing_key, arguments) + ) - def tx_select(self): + def tx_select(self) -> None: """Select standard transaction mode This method sets the channel to use standard transactions. The client @@ -373,9 +480,9 @@ def tx_select(self): or Rollback methods. """ - self._rpc(spec.Tx.Select()) + self._rpc(commands.Tx.Select()) - def tx_commit(self): + def tx_commit(self) -> None: """Commit the current transaction This method commits all message publications and acknowledgments @@ -383,16 +490,16 @@ def tx_commit(self): immediately after a commit. """ - self._rpc(spec.Tx.Commit()) + self._rpc(commands.Tx.Commit()) - def tx_rollback(self): + def tx_rollback(self) -> None: """Abandon the current transaction This method abandons all message publications and acknowledgments performed in the current transaction. A new transaction starts - immediately after a rollback. Note that unacked messages will not be + immediately after a rollback. Note that un-acked messages will not be automatically redelivered by rollback; if that is required an explicit recover call should be issued. """ - self._rpc(spec.Tx.Rollback()) + self._rpc(commands.Tx.Rollback()) diff --git a/rabbitpy/amqp_queue.py b/rabbitpy/amqp_queue.py index 6cc48a8..8be2b0d 100644 --- a/rabbitpy/amqp_queue.py +++ b/rabbitpy/amqp_queue.py @@ -26,14 +26,18 @@ message.ack() """ + +from __future__ import annotations + import logging -import warnings +import typing + +from pamqp import commands -from pamqp import specification +from rabbitpy import base, exceptions, exchange, message, utils -from rabbitpy import base -from rabbitpy import exceptions -from rabbitpy import utils +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan LOGGER = logging.getLogger(__name__) @@ -42,23 +46,18 @@ class Queue(base.AMQPClass): """Create and manage RabbitMQ queues. :param channel: The channel object to communicate on - :type channel: :py:class:`~rabbitpy.Channel` - :param str name: The name of the queue - :param exclusive: Queue can only be used by this channel and will + :param name: The queue name + :param exclusive: The queue can only be used by this channel and will auto-delete once the channel is closed. - :type exclusive: bool :param durable: Indicates if the queue should survive a RabbitMQ is restart - :type durable: bool - :param bool auto_delete: Automatically delete when all consumers disconnect - :param int max_length: Maximum queue length - :param int message_ttl: Time-to-live of a message in milliseconds - :param expires: Milliseconds until a queue is removed after becoming idle - :type expires: int + :param auto_delete: Automatically delete when all consumers disconnect + :param max_length: Maximum queue length + :param message_ttl: Time-to-live of a message in milliseconds + :param expires: Number of milliseconds until a queue is removed after + becoming idle :param dead_letter_exchange: Dead letter exchange for rejected messages - :type dead_letter_exchange: str :param dead_letter_routing_key: Routing key for dead lettered messages - :type dead_letter_routing_key: str - :param dict arguments: Custom arguments for the queue + :param arguments: Custom arguments for the queue :attributes: - **consumer_tag** (*str*) – Contains the consumer tag used to register @@ -68,7 +67,8 @@ class Queue(base.AMQPClass): :raises: :py:exc:`~rabbitpy.exceptions.RemoteCancellationException` """ - arguments = dict() + + arguments = {} auto_delete = False dead_letter_exchange = None dead_letter_routing_key = None @@ -79,11 +79,20 @@ class Queue(base.AMQPClass): message_ttl = None # pylint: disable=too-many-arguments - def __init__(self, channel, name='', - durable=False, exclusive=False, auto_delete=False, - max_length=None, message_ttl=None, expires=None, - dead_letter_exchange=None, dead_letter_routing_key=None, - arguments=None): + def __init__( + self, + channel: chan.Channel, + name: str = '', + durable: bool = False, + exclusive: bool = False, + auto_delete: bool = False, + max_length: int | None = None, + message_ttl: int | None = None, + expires: int | None = None, + dead_letter_exchange: str | None = None, + dead_letter_routing_key: str | None = None, + arguments: dict | None = None, + ): """Create a new Queue object instance. Only the :py:class:`rabbitpy.Channel` object is required. @@ -93,10 +102,12 @@ def __init__(self, channel, name='', have unintended consequences. """ - super(Queue, self).__init__(channel, name) + if name is None: + raise ValueError('Queue name may not be None') + super().__init__(channel, name) # Defaults - self.consumer_tag = 'rabbitpy.%s.%s' % (self.channel.id, id(self)) + self.consumer_tag = f'rabbitpy.{self.channel.id}.{id(self)}' self.consuming = False # Assign Arguments @@ -110,7 +121,7 @@ def __init__(self, channel, name='', self.dead_letter_exchange = dead_letter_exchange self.dead_letter_routing_key = dead_letter_routing_key - def __iter__(self): + def __iter__(self) -> typing.Iterable[message.Message]: """Quick way to consume messages using defaults of ``no_ack=False``, prefetch and priority not set. @@ -123,64 +134,77 @@ def __iter__(self): """ return self.consume() - def __len__(self): + def __len__(self) -> int: """Return the pending number of messages in the queue by doing a passive Queue declare. - :rtype: int - """ response = self._rpc(self._declare(True)) return response.message_count - def __setattr__(self, name, value): + def __setattr__(self, name: str, value: typing.Any) -> None: """Validate the data types for specific attributes when setting them, otherwise fall throw to the parent ``__setattr__`` - :param str name: The attribute to set - :param mixed value: The value to set + :param name: The attribute to set + :param value: The value to set :raises: ValueError """ if value is not None: - if (name in ['auto_delete', 'durable', 'exclusive'] and - not isinstance(value, bool)): - raise ValueError('%s must be True or False' % name) - elif (name in ['max_length', 'message_ttl', 'expires'] and - not isinstance(value, int)): - raise ValueError('%s must be an int' % name) - elif (name in ['consumer_tag', - 'dead_letter_exchange', - 'dead_letter_routing_key'] and - not utils.is_string(value)): - raise ValueError('%s must be a str, bytes or unicode' % name) + if name in [ + 'auto_delete', + 'durable', + 'exclusive', + ] and not isinstance(value, bool): + raise ValueError(f'{name} must be True or False') + elif name in [ + 'max_length', + 'message_ttl', + 'expires', + ] and not isinstance(value, int): + raise ValueError(f'{name} must be an int') + elif name in [ + 'consumer_tag', + 'dead_letter_exchange', + 'dead_letter_routing_key', + ] and not isinstance(value, (bytes, str)): + raise ValueError(f'{name} must be a str or bytes') elif name == 'arguments' and not isinstance(value, dict): raise ValueError('arguments must be a dict') - - # Set the value - super(Queue, self).__setattr__(name, value) - - def bind(self, source, routing_key=None, arguments=None): + super().__setattr__(name, value) + + def bind( + self, + source: exchange.ExchangeTypes, + routing_key: str | None = None, + arguments: dict | None = None, + ) -> bool: """Bind the queue to the specified exchange or routing key. - :type source: str or :py:class:`rabbitpy.exchange.Exchange` exchange :param source: The exchange to bind to - :param str routing_key: The routing key to use - :param dict arguments: Optional arguments for for RabbitMQ - :return: bool + :param routing_key: The routing key to use + :param arguments: Optional arguments for RabbitMQ """ if hasattr(source, 'name'): source = source.name - frame = specification.Queue.Bind(queue=self.name, - exchange=source, - routing_key=routing_key or '', - arguments=arguments) + frame = commands.Queue.Bind( + queue=self.name, + exchange=source, + routing_key=routing_key or '', + arguments=arguments, + ) response = self._rpc(frame) - return isinstance(response, specification.Queue.BindOk) - - def consume(self, no_ack=False, prefetch=None, priority=None, - consumer_tag=None): + return isinstance(response, commands.Queue.BindOk) + + def consume( + self, + no_ack: bool = False, + prefetch: int | None = None, + priority: int | None = None, + consumer_tag: str | None = None, + ) -> typing.Generator[message.Message, None, None]: """Consume messages from the queue as a :py:class:`generator`: .. code:: python @@ -191,29 +215,25 @@ def consume(self, no_ack=False, prefetch=None, priority=None, if you need to alter the prefect count, set the consumer priority or consume in no_ack mode. - .. versionadded:: 0.26 - .. warning:: You should only use a single :py:class:`~rabbitpy.Queue` instance per channel when consuming messages. Failure to do so can have unintended consequences. - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :param str consumer_tag: Optional consumer tag - :rtype: :py:class:`generator` + :param no_ack: Do not require acknowledgements + :param prefetch: Set a prefetch count for the channel + :param priority: Consumer priority + :param consumer_tag: Optional consumer tag :raises: :exc:`~rabbitpy.exceptions.RemoteCancellationException` """ if consumer_tag: self.consumer_tag = consumer_tag - self._consume(no_ack, prefetch, priority) + self._register_consumer(no_ack, prefetch, priority) try: while self.consuming: - # pylint: disable=protected-access - message = self.channel._consume_message() - if message: - yield message + value = self.channel.consume_message() + if value: + yield value else: if self.consuming: self.stop_consuming() @@ -222,48 +242,7 @@ def consume(self, no_ack=False, prefetch=None, priority=None, if self.consuming: self.stop_consuming() - def consume_messages(self, no_ack=False, prefetch=None, priority=None): - """Consume messages from the queue as a generator. - - .. warning:: This method is deprecated in favor of - :py:meth:`Queue.consume` and will be removed in future releases. - - .. deprecated:: 0.26 - - You can use this message instead of the queue object as an iterator - if you need to alter the prefect count, set the consumer priority or - consume in no_ack mode. - - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :rtype: :py:class:`Generator` - :raises: :exc:`~rabbitpy.exceptions.RemoteCancellationException` - - """ - warnings.warn('This method is deprecated in favor Queue.consume', - DeprecationWarning) - return self.consume(no_ack, prefetch, priority) - - # pylint: disable=no-self-use, unused-argument - def consumer(self, no_ack=False, prefetch=None, priority=None): - """Method for returning the contextmanager for consuming messages. You - should not use this directly. - - .. warning:: This method is deprecated and will be removed in a future - release. - - .. deprecated:: 0.26 - - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :return: None - - """ - raise DeprecationWarning() - - def declare(self, passive=False): + def declare(self, passive: bool = False) -> tuple[int, int]: """Declare the queue on the RabbitMQ channel passed into the constructor, returning the current message count for the queue and its consumer count as a tuple. @@ -279,18 +258,20 @@ def declare(self, passive=False): self.name = response.queue return response.message_count, response.consumer_count - def delete(self, if_unused=False, if_empty=False): + def delete(self, if_unused: bool = False, if_empty: bool = False) -> None: """Delete the queue - :param bool if_unused: Delete only if unused - :param bool if_empty: Delete only if empty + :param if_unused: Delete only if unused + :param if_empty: Delete only if empty """ - self._rpc(specification.Queue.Delete(queue=self.name, - if_unused=if_unused, - if_empty=if_empty)) + self._rpc( + commands.Queue.Delete( + queue=self.name, if_unused=if_unused, if_empty=if_empty + ) + ) - def get(self, acknowledge=True): + def get(self, acknowledge: bool = True) -> message.Message | None: """Request a single message from RabbitMQ using the Basic.Get AMQP command. @@ -299,26 +280,24 @@ def get(self, acknowledge=True): have unintended consequences. - :param bool acknowledge: Let RabbitMQ know if you will manually - acknowledge or negatively acknowledge the - message after each get. - :rtype: :class:`~rabbitpy.Message` or None + :param acknowledge: Let RabbitMQ know if you will manually + acknowledge or negatively acknowledge the + message after each get. """ - self._write_frame(specification.Basic.Get(queue=self.name, - no_ack=not acknowledge)) - - return self.channel._get_message() # pylint: disable=protected-access + self._write_frame( + commands.Basic.Get(queue=self.name, no_ack=not acknowledge) + ) + return self.channel.get_message() - def ha_declare(self, nodes=None): - """Declare a the queue as highly available, passing in a list of nodes + def ha_declare(self, nodes: list[str] | None = None) -> tuple[int, int]: + """Declare the queue as highly available, passing in a list of nodes the queue should live on. If no nodes are passed, the queue will be declared across all nodes in the cluster. :param list nodes: A list of nodes to declare. If left empty, queue will be declared on all cluster nodes. :return: Message count, Consumer count - :rtype: tuple(int, int) """ if nodes: @@ -330,11 +309,11 @@ def ha_declare(self, nodes=None): del self.arguments['x-ha-nodes'] return self.declare() - def purge(self): + def purge(self) -> None: """Purge the queue of all of its messages.""" - self._rpc(specification.Queue.Purge()) + self._rpc(commands.Queue.Purge(queue=self.name)) - def stop_consuming(self): + def stop_consuming(self) -> None: """Stop consuming messages. This is usually invoked if you want to cancel your consumer from outside the context manager or generator. @@ -346,47 +325,34 @@ def stop_consuming(self): return if not self.consuming: raise exceptions.NotConsumingError() - self.channel._cancel_consumer(self) # pylint: disable=protected-access + self.channel.cancel_consumer(self) self.consuming = False - def unbind(self, source, routing_key=None): + def unbind( + self, source: exchange.ExchangeTypes, routing_key: str | None = None + ) -> None: """Unbind queue from the specified exchange where it is bound the routing key. If routing key is None, use the queue name. - :type source: str or :py:class:`rabbitpy.exchange.Exchange` exchange :param source: The exchange to unbind from - :param str routing_key: The routing key that binds them + :param routing_key: The routing key that binds them """ if hasattr(source, 'name'): source = source.name routing_key = routing_key or self.name - self._rpc(specification.Queue.Unbind(queue=self.name, exchange=source, - routing_key=routing_key)) - - def _consume(self, no_ack=False, prefetch=None, priority=None): - """Return a :py:class:_Consumer instance as a contextmanager, properly - shutting down the consumer when the generator is exited. - - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Consumer priority - :return: _Consumer - - """ - if prefetch: - self.channel.prefetch_count(prefetch, False) - # pylint: disable=protected-access - self.channel._consume(self, no_ack, priority) - self.consuming = True - - def _declare(self, passive=False): - """Return a specification.Queue.Declare class pre-composed for the rpc + self._rpc( + commands.Queue.Unbind( + queue=self.name, exchange=source, routing_key=routing_key + ) + ) + + def _declare(self, passive: bool = False) -> commands.Queue.Declare: + """Return a commands.Queue.Declare class pre-composed for the rpc method since this can be called multiple times. - :param bool passive: Passive declare to retrieve message count and - consumer count information - :rtype: pamqp.specification.Queue.Declare + :param passive: Passive declare to retrieve message count and + consumer count information """ arguments = dict(self.arguments) @@ -399,16 +365,43 @@ def _declare(self, passive=False): if self.dead_letter_exchange: arguments['x-dead-letter-exchange'] = self.dead_letter_exchange if self.dead_letter_routing_key: - arguments['x-dead-letter-routing-key'] = \ + arguments['x-dead-letter-routing-key'] = ( self.dead_letter_routing_key + ) + + LOGGER.debug( + 'Declaring Queue %s, durable=%s, passive=%s, ' + 'exclusive=%s, auto_delete=%s, arguments=%r', + self.name, + self.durable, + passive, + self.exclusive, + self.auto_delete, + arguments, + ) + return commands.Queue.Declare( + queue=self.name, + durable=self.durable, + passive=passive, + exclusive=self.exclusive, + auto_delete=self.auto_delete, + arguments=arguments, + ) + + def _register_consumer( + self, + no_ack: bool = False, + prefetch: int | None = None, + priority: int | None = None, + ) -> None: + """Start consuming on the channel, optionally setting the prefect count + + :param no_ack: Do not require acknowledgements + :param prefetch: Set a prefetch count for the channel + :param priority: Consumer priority - LOGGER.debug('Declaring Queue %s, durable=%s, passive=%s, ' - 'exclusive=%s, auto_delete=%s, arguments=%r', - self.name, self.durable, passive, self.exclusive, - self.auto_delete, arguments) - return specification.Queue.Declare(queue=self.name, - durable=self.durable, - passive=passive, - exclusive=self.exclusive, - auto_delete=self.auto_delete, - arguments=arguments) + """ + if prefetch: + self.channel.prefetch_count(prefetch, False) + self.channel.register_consumer(self, no_ack, priority) + self.consuming = True diff --git a/rabbitpy/base.py b/rabbitpy/base.py index cd1f18d..0c8ea32 100644 --- a/rabbitpy/base.py +++ b/rabbitpy/base.py @@ -2,36 +2,59 @@ Base classes for various parts of rabbitpy """ + +from __future__ import annotations + import logging +import queue +import socket import threading +import typing -from pamqp import specification +from pamqp import base, body, commands, header, heartbeat -from rabbitpy import exceptions -from rabbitpy import utils -from rabbitpy.utils import queue +from rabbitpy import exceptions, utils +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan + from rabbitpy import connection as conn + from rabbitpy import message LOGGER = logging.getLogger(__name__) -class ChannelWriter(object): # pylint: disable=too-few-public-methods +class Interrupt(typing.TypedDict): + event: threading.Event + callback: typing.Callable | None + args: typing.Iterable[typing.Any] | None + + +FrameTypes = typing.Union[str, type[base.Frame], list[str | type[base.Frame]]] + +class ChannelWriter: # pylint: disable=too-few-public-methods """The AMQP Adapter provides a more generic, non-opinionated interface to RabbitMQ by providing methods that map to the AMQP API. - :param channel: The channel to use - :type channel: rabbitpy.channel.Channel + :param channel: The channel to write to """ - def __init__(self, channel): + + def __init__(self, channel: chan.Channel): self.channel = channel - def _rpc(self, frame_value): + def _rpc( + self, frame_value: base.Frame + ) -> ( + base.Frame + | message.Message + | commands.Basic.GetOk + | commands.Basic.GetEmpty + | commands.Queue.DeclareOk + ): """Execute the RPC command for the frame. - :param pamqp.specification.Frame frame_value: The frame to send - :rtype: pamqp.specification.Frame or pamqp.message.Message + :param frame_value: The frame to send """ LOGGER.debug('Issuing RPC to RabbitMQ: %r', frame_value) @@ -39,10 +62,10 @@ def _rpc(self, frame_value): raise exceptions.ChannelClosedException() return self.channel.rpc(frame_value) - def _write_frame(self, frame_value): + def _write_frame(self, frame_value: base.Frame) -> None: """Write a frame to the channel's connection - :param pamqp.specification.Frame frame_value: The frame to send + :param frame_value: The frame to send """ self.channel.write_frame(frame_value) @@ -50,155 +73,148 @@ def _write_frame(self, frame_value): class AMQPClass(ChannelWriter): # pylint: disable=too-few-public-methods """Base Class object AMQP object classes""" - def __init__(self, channel, name): + + def __init__(self, channel: chan.Channel, name: str): """Create a new ClassObject. :param channel: The channel to execute commands on - :type channel: rabbitpy.Channel - :param str name: Set the name + :param name: Set the name :raises: ValueError """ - super(AMQPClass, self).__init__(channel) - # Use type so there's not a circular dependency - if channel.__class__.__name__ != 'Channel': + super().__init__(channel) + from rabbitpy import channel as chan_mod # local import to avoid cycle + + if not isinstance(channel, chan_mod.Channel): raise ValueError('channel must be a valid rabbitpy Channel object') - elif not utils.is_string(name): - raise ValueError('name must be str, bytes or unicode') + if not isinstance(name, str): + raise ValueError('name must be a string') self.name = name -class StatefulObject(object): +class StatefulObject(utils.DebuggingOptimizationMixin): """Base object for rabbitpy classes that need to maintain state such as connection and channel. """ - CLOSED = 0x00 - CLOSING = 0x01 - OPEN = 0x02 - OPENING = 0x03 - STATES = {0x00: 'Closed', - 0x01: 'Closing', - 0x02: 'Open', - 0x03: 'Opening'} + CLOSED = 0 + CLOSING = 1 + OPEN = 2 + OPENING = 3 + + STATES = {0: 'Closed', 1: 'Closing', 2: 'Open', 3: 'Opening'} def __init__(self): """Create a new instance of the object defaulting to a closed state.""" - self._state = self.CLOSED + super().__init__() + self._state: int = self.CLOSED - def _set_state(self, value): + def _set_state(self, value: int) -> None: """Set the state to the specified value, validating it is a supported state value. - :param int value: The new state value - :raises: ValueError + :param value: The new state value + """ - if value not in list(self.STATES.keys()): - raise ValueError('Invalid state value: %r' % value) - LOGGER.debug('%s setting state to %r', - self.__class__.__name__, self.STATES[value]) + if value not in self.STATES.keys(): + raise ValueError(f'Invalid state value: {value!r}') + if self._is_debugging: + LOGGER.debug( + '%s setting state to %r', + self.__class__.__name__, + self.STATES[value], + ) self._state = value @property - def closed(self): - """Returns True if in the CLOSED runtime state - - :rtype: bool - - """ + def closed(self) -> bool: + """Returns True if in the CLOSED runtime state""" return self._state == self.CLOSED @property - def closing(self): - """Returns True if in the CLOSING runtime state - - :rtype: bool - - """ + def closing(self) -> bool: + """Returns True if in the CLOSING runtime state""" return self._state == self.CLOSING @property - def open(self): - """Returns True if in the OPEN runtime state - - :rtype: bool - - """ + def open(self) -> bool: + """Returns True if in the OPEN runtime state""" return self._state == self.OPEN @property - def opening(self): - """Returns True if in the OPENING runtime state - - :rtype: bool - - """ + def opening(self) -> bool: + """Returns True if in the OPENING runtime state""" return self._state == self.OPENING @property - def state(self): - """Return the runtime state value - - :rtype: int - - """ + def state(self) -> int: + """Return the runtime state value""" return self._state @property - def state_description(self): - """Returns the text based description of the runtime state - - :rtype: str - - """ + def state_description(self) -> str: + """Returns the text based description of the runtime state""" return self.STATES[self._state] class AMQPChannel(StatefulObject): """Base AMQP Channel Object""" - CLOSE_REQUEST_FRAME = specification.Channel.Close + CLOSE_REQUEST_FRAME = commands.Channel.Close DEFAULT_CLOSE_CODE = 200 DEFAULT_CLOSE_REASON = 'Normal Shutdown' REMOTE_CLOSED = 0x04 - def __init__(self, exception_queue, write_trigger, connection, - blocking_read=False): - super(AMQPChannel, self).__init__() + def __init__( + self, + exception_queue: queue.Queue, + write_trigger: socket.socket, + connection: conn.Connection, + blocking_read: bool = False, + ): + super().__init__() if blocking_read: LOGGER.debug('Initialized with blocking read') - self.blocking_read = blocking_read - self._debugging = None - self._interrupt = {'event': threading.Event(), - 'callback': None, - 'args': None} - self._channel_id = None - self._connection = connection - self._exceptions = exception_queue - self._state = self.CLOSED - self._read_queue = None - self._waiting = False + self.blocking_read: bool = blocking_read + self._interrupt: Interrupt = { + 'event': threading.Event(), + 'callback': None, + 'args': None, + } + self._channel_id: int | None = None + self._connection: conn.Connection = connection + self._exceptions: queue.Queue = exception_queue + self._read_queue: queue.Queue | None = None + self._waiting: bool = False self._write_lock = threading.Lock() - self._write_queue = None - self._write_trigger = write_trigger + self._write_queue: queue.Queue | None = None + self._write_trigger: socket.socket = write_trigger - def __int__(self): + def __int__(self) -> int: + """Return the numeric channel ID""" + if self._channel_id is None: + raise ValueError('Channel ID is not yet assigned') return self._channel_id - def close(self): + def close(self) -> None: """Close the AMQP channel""" if self._connection.closed: LOGGER.debug('Connection is closed, bailing') return if self.closed: - LOGGER.debug('AMQPChannel %i close invoked and already closed', - self._channel_id) + LOGGER.debug( + 'AMQPChannel %i close invoked and already closed', + self._channel_id, + ) return - LOGGER.debug('Channel %i close invoked while %s', - self._channel_id, self.state_description) + LOGGER.debug( + 'Channel %i close invoked while %s', + self._channel_id, + self.state_description, + ) # Make sure there are no RPC frames pending self._check_for_pending_frames() @@ -208,19 +224,23 @@ def close(self): frame_value = self._build_close_frame() if self._is_debugging: - LOGGER.debug('Channel %i Waiting for a valid response for %s', - self._channel_id, frame_value.name) + LOGGER.debug( + 'Channel %i Waiting for a valid response for %s', + self._channel_id, + frame_value.name, + ) self.rpc(frame_value) - self._set_state(self.CLOSED) if self._is_debugging: LOGGER.debug('Channel #%i closed', self._channel_id) + self._set_state(self.CLOSED) - def rpc(self, frame_value): - """Send a RPC command to the remote server. This should not be directly - invoked. + def rpc( + self, frame_value: base.Frame + ) -> base.Frame | commands.Queue.DeclareOk | None: + """Send an RPC command to the remote server. This should not be + directly invoked. - :param pamqp.specification.Frame frame_value: The frame to send - :rtype: pamqp.specification.Frame or None + :param frame_value: The frame to send """ if self.closed: @@ -231,57 +251,51 @@ def rpc(self, frame_value): if frame_value.synchronous: return self._wait_on_frame(frame_value.valid_responses) - def wait_for_confirmation(self): - """Used by the Message.publish method when publisher confirmations are - enabled. - - :rtype: pamqp.frame.Frame + def wait_for_confirmation(self) -> base.Frame: + """Used by the `Message.publish` method when publisher confirmations + are enabled. """ - return self._wait_on_frame([specification.Basic.Ack, - specification.Basic.Nack]) + return self._wait_on_frame([commands.Basic.Ack, commands.Basic.Nack]) - def write_frame(self, frame): + def write_frame(self, value: base.Frame | heartbeat.Heartbeat) -> None: """Put the frame in the write queue for the IOWriter object to write to the socket when it can. This should not be directly invoked. - :param pamqp.specification.Frame frame: The frame to write + :param value: The frame to write """ if self._can_write(): if self._is_debugging: - LOGGER.debug('Writing frame: %s', frame.name) + LOGGER.debug('Writing frame: %s', value.name) with self._write_lock: - self._write_queue.put((self._channel_id, frame)) + self._write_queue.put((self._channel_id, value)) self._trigger_write() - def write_frames(self, frames): + def write_frames(self, frames: list[base.Frame]) -> None: """Add a list of frames for the IOWriter object to write to the socket when it can. - :param list frames: The list of frame to write + :param frames: The list of frame to write """ if self._can_write(): if self._is_debugging: - LOGGER.debug('Writing frames: %r', - [frame.name for frame in frames]) + LOGGER.debug( + 'Writing frames: %r', [frame.name for frame in frames] + ) with self._write_lock: - # pylint: disable=expression-not-assigned - [self._write_queue.put((self._channel_id, frame)) - for frame in frames] + for frame in frames: + self._write_queue.put((self._channel_id, frame)) self._trigger_write() - def _build_close_frame(self): - """Return the proper close frame for this object. - - :rtype: pamqp.specification.Channel.Close + def _build_close_frame(self) -> commands.Channel.Close: + """Return the proper close frame for this object.""" + return self.CLOSE_REQUEST_FRAME( + self.DEFAULT_CLOSE_CODE, self.DEFAULT_CLOSE_REASON + ) - """ - return self.CLOSE_REQUEST_FRAME(self.DEFAULT_CLOSE_CODE, - self.DEFAULT_CLOSE_REASON) - - def _can_write(self): + def _can_write(self) -> bool: self._check_for_exceptions() if self._connection.closed: raise exceptions.ConnectionClosed() @@ -289,20 +303,7 @@ def _can_write(self): raise exceptions.ChannelClosedException() return True - @property - def _is_debugging(self): - """Indicates that something has set the logger to ``logging.DEBUG`` - to perform a minor micro-optimization preventing ``LOGGER.debug`` calls - when they are not required. - - :return: bool - - """ - if self._debugging is None: - self._debugging = LOGGER.getEffectiveLevel() == logging.DEBUG - return self._debugging - - def _check_for_exceptions(self): + def _check_for_exceptions(self) -> None: """Check if there are any queued exceptions to raise, raising it if there is. @@ -312,35 +313,39 @@ def _check_for_exceptions(self): self._exceptions.task_done() raise exception - def _check_for_pending_frames(self): + def _check_for_pending_frames(self) -> None: value = self._read_from_queue() if value: self._check_for_rpc_request(value) - LOGGER.debug('Read frame while shutting down: %r', value) + if self._is_debugging: + LOGGER.debug('Read frame while shutting down: %r', value) - def _check_for_rpc_request(self, value): + def _check_for_rpc_request(self, value: base.Frame) -> None: """Implement in child objects to inspect frames for channel specific RPC requests from RabbitMQ. """ - if isinstance(value, specification.Channel.Close): - LOGGER.debug('Channel closed') + if isinstance(value, commands.Channel.Close): + if self._is_debugging: + LOGGER.debug('Channel closed') self._on_remote_close(value) - def _force_close(self): + def _force_close(self) -> None: """Force the channel to mark itself as closed""" self._set_state(self.CLOSED) - LOGGER.debug('Channel #%i closed', self._channel_id) + if self._is_debugging: + LOGGER.debug('Channel #%i closed', self._channel_id) - def _interrupt_wait_on_frame(self, callback, *args): + def _interrupt_wait_on_frame( + self, callback: typing.Callable, *args + ) -> None: """Invoke to interrupt the current self._wait_on_frame blocking loop in order to allow for a flow such as waiting on a full message while consuming. Will wait until the ``_wait_on_frame_interrupt`` is cleared to make this a blocking operation. :param callback: The method to call - :type callback: typing.Callable - :param list args: Args to pass to the callback + :param args: Args to pass to the callback """ self._check_for_exceptions() @@ -354,41 +359,39 @@ def _interrupt_wait_on_frame(self, callback, *args): self._interrupt['event'].set() @property - def _interrupt_is_set(self): + def _interrupt_is_set(self) -> bool: return self._interrupt['event'].is_set() - def _on_interrupt_set(self): - # pylint: disable=not-an-iterable,not-callable + def _on_interrupt_set(self) -> None: + """Invoke interrupt value then clear out stack""" self._interrupt['callback'](*self._interrupt['args']) self._interrupt['event'].clear() self._interrupt['callback'] = None self._interrupt['args'] = None - def _on_remote_close(self, value): + def _on_remote_close(self, value: commands.Channel.Close) -> None: """Handle RabbitMQ remotely closing the channel :param value: The Channel.Close method frame - :type value: pamqp.spec.Channel.Close :raises: exceptions.RemoteClosedChannelException :raises: exceptions.AMQPException """ self._set_state(self.REMOTE_CLOSED) if value.reply_code in exceptions.AMQP: - LOGGER.error('Received remote close (%s): %s', - value.reply_code, value.reply_text) + LOGGER.error( + 'Received remote close (%s): %s', + value.reply_code, + value.reply_text, + ) raise exceptions.AMQP[value.reply_code](value) else: - raise exceptions.RemoteClosedChannelException(self._channel_id, - value.reply_code, - value.reply_text) - - def _read_from_queue(self): - """Check to see if a frame is in the queue and if so, return it + raise exceptions.RemoteClosedChannelException( + self._channel_id, value.reply_code, value.reply_text + ) - :rtype: amqp.specification.Frame or None - - """ + def _read_from_queue(self) -> base.Frame | commands.Basic.Deliver | None: + """Check to see if a frame is in the queue and if so, return it""" if self._can_write() and not self.closing and self.blocking_read: if self._is_debugging: LOGGER.debug('Performing a blocking read') @@ -396,28 +399,33 @@ def _read_from_queue(self): self._read_queue.task_done() else: try: - value = self._read_queue.get(True, .1) + # 10 ms timeout — short enough that publisher confirms + # and other synchronous RPCs do not busy-wait for long + # (#73: reduces per-message latency ~10x vs 100 ms). + value = self._read_queue.get(True, 0.01) self._read_queue.task_done() except queue.Empty: value = None return value - def _trigger_write(self): + def _trigger_write(self) -> None: """Notifies the IO loop we need to write a frame by writing a byte to a local socket. """ - utils.trigger_write(self._write_trigger) - - def _validate_frame_type(self, frame_value, frame_type): + try: + self._write_trigger.send(b'0') + except OSError: + pass + + def _validate_frame_type( + self, frame_value: base.Frame, frame_type: FrameTypes + ) -> bool: """Validate the frame value against the frame type. The frame type can be an individual frame type or a list of frame types. :param frame_value: The frame to check - :type frame_value: pamqp.specification.Frame :param frame_type: The frame(s) to check against - :type frame_type: pamqp.specification.Frame or list - :rtype: bool """ if frame_value is None: @@ -433,20 +441,27 @@ def _validate_frame_type(self, frame_value, frame_type): if result: return True return False - elif isinstance(frame_value, specification.Frame): + elif isinstance(frame_value, commands.Frame): return frame_value.name == frame_type.name return False - def _wait_on_frame(self, frame_type=None): + def _wait_on_frame( + self, frame_type: FrameTypes + ) -> ( + base.Frame + | commands.Basic.Deliver + | commands.Queue.DeclareOk + | header.ContentHeader + | body.ContentBody + | None + ): """Read from the queue, blocking until a result is returned. An individual frame type or a list of frame types can be passed in to wait for specific frame types. If there is no match on the frame retrieved from the queue, put the frame back in the queue and recursively call the method. - :param frame_type: The name or list of names of the frame type(s) - :type frame_type: str|list|pamqp.specification.Frame - :rtype: Frame + :param frame_type: The name, frame type, list of names or frame types """ self._check_for_exceptions() @@ -456,9 +471,11 @@ def _wait_on_frame(self, frame_type=None): LOGGER.debug('Waiting on %r frame(s)', frame_type) start_state = self.state self._waiting = True - while (start_state == self.state and - not self.closed and - not self._connection.closed): + while ( + start_state == self.state + and not self.closed + and not self._connection.closed + ): value = self._read_from_queue() if value is not None: self._check_for_rpc_request(value) diff --git a/rabbitpy/channel.py b/rabbitpy/channel.py index 9bc6174..ffe3a13 100644 --- a/rabbitpy/channel.py +++ b/rabbitpy/channel.py @@ -5,15 +5,30 @@ the communication between the IO thread and the higher-level objects. """ -import logging -from pamqp import specification as spec -from pamqp import PYTHON3 +from __future__ import annotations -from rabbitpy import base -from rabbitpy import exceptions -from rabbitpy import message -from rabbitpy.utils import queue +import logging +import queue +import socket +import types +from typing import TYPE_CHECKING + +from pamqp import base as pamqp_base +from pamqp import commands, header + +from rabbitpy import ( + base, + exceptions, + message, +) +from rabbitpy import ( + events as rabbitpy_events, +) + +if TYPE_CHECKING: + from rabbitpy import amqp, amqp_queue + from rabbitpy import connection as conn LOGGER = logging.getLogger(__name__) @@ -45,32 +60,38 @@ class Channel(base.AMQPChannel): :param int channel_id: The channel # to use for this instance :param dict server_capabilities: Features the server supports :param events: Event management object - :type events: rabbitpy.Events :param exception_queue: Exception queue - :type exception_queue: queue.Queue - :param read_queue: Queue to read pending frames from - :type read_queue: queue.Queue - :param write_queue: Queue to write pending AMQP objs to - :type write_queue: queue.Queue - :param int maximum_frame_size: The max frame size for msg bodies - :param socket write_trigger: Write to this socket to break IO waiting - :param bool blocking_read: Use blocking Queue.get to improve performance + :param read_queue: The queue to read pending frames from + :param write_queue: The queue to write pending AMQP objs to + :param maximum_frame_size: The max frame size for msg bodies + :param write_trigger: Write to this socket to break IO waiting + :param blocking_read: Use blocking `Queue.get` to improve performance :raises: rabbitpy.exceptions.RemoteClosedChannelException :raises: rabbitpy.exceptions.AMQPException """ + STATES = base.AMQPChannel.STATES STATES[0x04] = 'Remotely Closed' - def __init__(self, channel_id, server_capabilities, events, - exception_queue, read_queue, write_queue, - maximum_frame_size, write_trigger, connection, - blocking_read=False): + def __init__( + self, + channel_id: int, + server_capabilities: dict, + events: rabbitpy_events.Events, + exception_queue: queue.Queue, + read_queue: queue.Queue, + write_queue: queue.Queue, + maximum_frame_size: int, + write_trigger: socket.socket, + connection: conn.Connection, + blocking_read: bool = False, + ): """Create a new instance of the Channel class""" - super(Channel, self).__init__(exception_queue, write_trigger, - connection, blocking_read) + super().__init__( + exception_queue, write_trigger, connection, blocking_read + ) self._channel_id = channel_id - self._consumers = {} self._consuming = False self._events = events self._maximum_frame_size = maximum_frame_size @@ -78,17 +99,21 @@ def __init__(self, channel_id, server_capabilities, events, self._read_queue = read_queue self._write_queue = write_queue self._server_capabilities = server_capabilities + self.consumers = {} - def __enter__(self): + def __enter__(self) -> Channel: """For use as a context manager, return a handle to this object instance. - :rtype: Channel - """ return self - def __exit__(self, exc_type, exc_val, unused_exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ): """When leaving the context, examine why the context is leaving, if it's an exception or what. @@ -100,31 +125,58 @@ def __exit__(self, exc_type, exc_val, unused_exc_tb): if self.open: self.close() - def close(self): + def cancel_consumer( + self, + obj: amqp.AMQP | amqp_queue.Queue, + consumer_tag: str | None = None, + nowait: bool = False, + ) -> None: + """Cancel the consuming of a queue. + + .. note:: This is a low level method and should not be directly used. + + :param obj: The queue to cancel + :param consumer_tag: The consumer tag to cancel + :param nowait: Do not wait on RPC to finish before returning + + """ + consumer_tag = consumer_tag or obj.consumer_tag + self._interrupt_wait_on_frame( + self._on_ready_to_cancel, consumer_tag, nowait + ) + + def close(self) -> None: """Close the channel, cancelling any active consumers, purging the read queue, while looking to see if a Basic.Nack should be sent, sending it if so. """ if self._connection.closed: - LOGGER.debug('Channel %i close invoked when connection closed', - self._channel_id) + LOGGER.debug( + 'Channel %i close invoked when connection closed', + self._channel_id, + ) elif self.closed: - LOGGER.debug('Channel %i close invoked when already closed', - self._channel_id) + LOGGER.debug( + 'Channel %i close invoked when already closed', + self._channel_id, + ) else: self._set_state(self.CLOSING) # Empty the queue and nack the max id (and all previous) - if self._consumers: + if self.consumers: delivery_tag = 0 discard_counter = 0 ack_tags = [] - for queue_obj, no_ack in self._consumers.values(): - self._cancel_consumer(queue_obj) + for queue_obj, no_ack in self.consumers.values(): + self.cancel_consumer(queue_obj) if not no_ack: - LOGGER.debug('Channel %i will nack messages for %s', - self._channel_id, queue_obj.consumer_tag) + LOGGER.debug( + 'Channel %i will nack messages for %s', + self._channel_id, + queue_obj.consumer_tag, + ) ack_tags.append(queue_obj.consumer_tag) # If there are any ack tags, get the last msg to nack @@ -133,201 +185,190 @@ def close(self): frame_value = self._get_from_read_queue() if not frame_value: break - if (frame_value.name == BASIC_DELIVER and - frame_value.consumer_tag in ack_tags): + if ( + frame_value.name == BASIC_DELIVER + and frame_value.consumer_tag in ack_tags + ): if delivery_tag < frame_value.delivery_tag: delivery_tag = frame_value.delivery_tag discard_counter += 1 if delivery_tag: self._multi_nack(delivery_tag) - super(Channel, self).close() + super().close() - def enable_publisher_confirms(self): + def consume_message(self) -> message.Message | None: + """Get a message from the stack, blocking while doing so. If a consumer + is cancelled out-of-band, we will receive a Basic.CancelOk + instead. + + .. note:: This is a low level method and should not be called directly. + + """ + if not self.consumers: + raise exceptions.NotConsumingError + frame = self._wait_on_frame([commands.Basic.Deliver]) + LOGGER.debug('Waited on frame, got %r', frame) + if frame: + return self._wait_for_content_frames(frame) + return None + + def enable_publisher_confirms(self) -> None: """Turn on Publisher Confirms. If confirms are turned on, the - Message.publish command will return a bool indicating if a message has - been successfully published. + `Message.publish` command will return a bool indicating if a message + has been successfully published. """ if not self._supports_publisher_confirms: raise exceptions.NotSupportedError('Confirm.Select') - self.rpc(spec.Confirm.Select()) + self.rpc(commands.Confirm.Select()) self._publisher_confirms = True @property - def id(self): # pylint: disable=invalid-name - """Return the channel id - - :rtype: int - - """ + def id(self) -> int: # pylint: disable=invalid-name + """Return the channel id""" return self._channel_id @property - def maximum_frame_size(self): - """Return the AMQP maximum frame size - - :rtype: int - - """ + def maximum_frame_size(self) -> int: + """Return the AMQP maximum frame size""" return self._maximum_frame_size - def open(self): - """Open the channel, invoked directly upon creation by the Connection - - """ + def open(self) -> None: + """Open the channel, invoked upon creation by the Connection""" self._set_state(self.OPENING) self.write_frame(self._build_open_frame()) - self._wait_on_frame(spec.Channel.OpenOk) + self._wait_on_frame(commands.Channel.OpenOk) self._set_state(self.OPEN) LOGGER.debug('Channel #%i open', self._channel_id) - def prefetch_count(self, value, all_channels=False): + def prefetch_count(self, value: int, all_channels: bool = False) -> None: """Set a prefetch count for the channel (or all channels on the same connection). - :param int value: The prefetch count to set - :param bool all_channels: Set the prefetch count on all channels on the - same connection + :param value: The prefetch count to value + :param all_channels: Set the prefetch count on all channels on the + same connection """ - self.rpc(spec.Basic.Qos(prefetch_count=value, global_=all_channels)) + self.rpc( + commands.Basic.Qos(prefetch_count=value, global_=all_channels) + ) - def prefetch_size(self, value, all_channels=False): + def prefetch_size(self, value: int, all_channels: bool = False) -> None: """Set a prefetch size in bytes for the channel (or all channels on the same connection). - :param int value: The prefetch size to set + :param int value: The prefetch size value :param bool all_channels: Set the prefetch size on all channels on the same connection """ - if value is None: - return - self.rpc(spec.Basic.Qos(prefetch_size=value, global_=all_channels)) + if value: + self.rpc( + commands.Basic.Qos(prefetch_size=value, global_=all_channels) + ) @property - def publisher_confirms(self): - """Returns True if publisher confirms are enabled. - - :rtype: bool - - """ + def publisher_confirms(self) -> bool: + """Returns True if publisher confirms are enabled.""" return self._publisher_confirms - def recover(self, requeue=False): + def recover(self, requeue: bool = False) -> None: """Recover all unacknowledged messages that are associated with this channel. - :param bool requeue: Requeue the message + :param requeue: Requeue the message """ - self.rpc(spec.Basic.Recover(requeue=requeue)) - - @staticmethod - def _build_open_frame(): - """Build and return a channel open frame - - :rtype: pamqp.spec.Channel.Open - - """ - return spec.Channel.Open() - - def _cancel_consumer(self, obj, consumer_tag=None, nowait=False): - """Cancel the consuming of a queue. - - :param rabbitpy.amqp_queue.Queue obj: The queue to cancel - - """ - consumer_tag = consumer_tag or obj.consumer_tag - self._interrupt_wait_on_frame(self._on_ready_to_cancel, - consumer_tag, nowait) - - def _on_ready_to_cancel(self, consumer_tag, nowait): - self._check_for_exceptions() - if self.closed: - return - LOGGER.debug('Cancelling consumer while %r (%r)', - self.state_description, - self._connection.state_description) - if consumer_tag in self._consumers: - del self._consumers[consumer_tag] - if nowait: - self.write_frame(spec.Basic.Cancel(consumer_tag=consumer_tag, - nowait=True)) - return - self.rpc(spec.Basic.Cancel(consumer_tag=consumer_tag)) - - def _check_for_rpc_request(self, value): - """Inspect a frame to see if it's a RPC request from RabbitMQ. - - :param spec.Frame value: - - """ - LOGGER.debug('Checking for RPC request: %r', value) - super(Channel, self)._check_for_rpc_request(value) - if isinstance(value, spec.Basic.Return): - raise exceptions.MessageReturnedException(value.reply_code, - value.reply_text, - value.exchange, - value.routing_key) - elif isinstance(value, spec.Basic.Cancel): - self._waiting = False - if value.consumer_tag in self._consumers: - del self._consumers[value.consumer_tag] - raise exceptions.RemoteCancellationException(value.consumer_tag) - - def _consume(self, obj, no_ack, priority=None): + self.rpc(commands.Basic.Recover(requeue=requeue)) + + def register_consumer( + self, + obj: amqp_queue.Queue, + no_ack: bool, + priority: int | None = None, + ) -> None: """Register a Queue object as a consumer, issuing Basic.Consume. + .. note:: This is a low level method and should not be called directly. + :param obj: The queue to consume - :type obj: rabbitpy.amqp_queue.Queue - :param bool no_ack: no_ack mode - :param int priority: Consumer priority + :param no_ack: no_ack mode + :param priority: Consumer priority :raises: ValueError """ - args = dict() + args = {} if priority is not None: if not self._supports_consumer_priorities: raise exceptions.NotSupportedError('consumer_priorities') if not isinstance(priority, int): raise ValueError('Consumer priority must be an int') args['x-priority'] = priority - self.rpc(spec.Basic.Consume(queue=obj.name, - consumer_tag=obj.consumer_tag, - no_ack=no_ack, - arguments=args)) - self._consumers[obj.consumer_tag] = (obj, no_ack) + self.rpc( + commands.Basic.Consume( + queue=obj.name, + consumer_tag=obj.consumer_tag, + no_ack=no_ack, + arguments=args, + ) + ) + self.consumers[obj.consumer_tag] = (obj, no_ack) - def _consume_message(self): - """Get a message from the stack, blocking while doing so. If a consumer - is cancelled out-of-band, we will receive a Basic.CancelOk - instead. + @staticmethod + def _build_open_frame() -> commands.Channel.Open: + """Build and return a channel open frame""" + return commands.Channel.Open() - :rtype: rabbitpy.message.Message + def _on_ready_to_cancel(self, consumer_tag: str, nowait: bool) -> None: + self._check_for_exceptions() + if self.closed: + return + LOGGER.debug( + 'Cancelling consumer while %r (%r)', + self.state_description, + self._connection.state_description, + ) + if consumer_tag in self.consumers: + del self.consumers[consumer_tag] + if nowait: + self.write_frame( + commands.Basic.Cancel(consumer_tag=consumer_tag, nowait=True) + ) + return + self.rpc(commands.Basic.Cancel(consumer_tag=consumer_tag)) - """ - if not self._consumers: - raise exceptions.NotConsumingError - frame_value = self._wait_on_frame([spec.Basic.Deliver]) - LOGGER.debug('Waited on frame, got %r', frame_value) - if frame_value: - return self._wait_for_content_frames(frame_value) - return None + def _check_for_rpc_request(self, value: pamqp_base.Frame) -> None: + """Inspect a frame to see if it's an RPC request from RabbitMQ.""" + LOGGER.debug('Checking for RPC request: %r', value) + super()._check_for_rpc_request(value) + if isinstance(value, commands.Basic.Return): + raise exceptions.MessageReturnedException( + value.reply_code, + value.reply_text, + value.exchange, + value.routing_key, + ) + elif isinstance(value, commands.Basic.Cancel): + self._waiting = False + if value.consumer_tag in self.consumers: + del self.consumers[value.consumer_tag] + raise exceptions.RemoteCancellationException(value.consumer_tag) - def _create_message(self, method_frame, header_frame, body): + def _create_message( + self, + method_frame: pamqp_base.Frame | None, + header_frame: header.ContentHeader | None, + body: str | bytes | None, + ) -> message.Message | None: """Create a message instance with the channel it was received on and the dictionary of message parts. Will return None if no message can be created. :param method_frame: The method frame value - :type method_frame: pamqp.specification.Frame :param header_frame: Header frame value - :type header_frame: pamqp.header.ContentHeader or None :param body: The message body - :type body: str or None - :rtype: rabbitpy.message.Message or None """ if not method_frame: @@ -335,18 +376,16 @@ def _create_message(self, method_frame, header_frame, body): return None if not header_frame: LOGGER.debug('Malformed header frame: %r', header_frame) - props = header_frame.properties.to_dict() if header_frame else dict() + props = header_frame.properties.to_dict() if header_frame else {} msg = message.Message(self, body, props) msg.method = method_frame msg.name = method_frame.name return msg - def _get_from_read_queue(self): + def _get_from_read_queue(self) -> pamqp_base.Frame | None: """Fetch a frame from the read queue and return it, otherwise return None - :rtype: pamqp.specification.Frame - """ try: frame_value = self._read_queue.get(False) @@ -359,109 +398,105 @@ def _get_from_read_queue(self): pass return frame_value - def _get_message(self): - """Try and get a delivered message from the connection's message stack. + def get_message(self) -> message.Message | None: + """Try and get a delivered message from the connection's stack. - :rtype: rabbitpy.message.Message or None + .. note: This is for internal use only """ - frame_value = self._wait_on_frame([spec.Basic.GetOk, - spec.Basic.GetEmpty]) - if isinstance(frame_value, spec.Basic.GetEmpty): + frame_value = self._wait_on_frame( + [commands.Basic.GetOk, commands.Basic.GetEmpty] + ) + if isinstance(frame_value, commands.Basic.GetEmpty): return None return self._wait_for_content_frames(frame_value) - def _multi_nack(self, delivery_tag, requeue=True): + def _multi_nack(self, delivery_tag: int, requeue: bool = True) -> None: """Send a multiple negative acknowledgement, re-queueing the items - :param int delivery_tag: The delivery tag for this channel - :param bool requeue: Requeue the messages + :param delivery_tag: The delivery tag for this channel + :param requeue: Requeue the messages """ if not self._supports_basic_nack: raise exceptions.NotSupportedError('Basic.Nack') if self._is_debugging: LOGGER.debug('Sending Basic.Nack with requeue') - self.rpc(spec.Basic.Nack(delivery_tag=delivery_tag, - multiple=True, - requeue=requeue)) - - def _reject_inbound_message(self, method_frame): + self.rpc( + commands.Basic.Nack( + delivery_tag=delivery_tag, multiple=True, requeue=requeue + ) + ) + + def _reject_inbound_message( + self, method_frame: commands.Basic.Deliver + ) -> None: """Used internally to reject a message when it's been received during a state that it should not have been. - :param pamqp.specification.Basic.Deliver method_frame: The method frame + :param method_frame: The method frame """ - self.rpc(spec.Basic.Reject(delivery_tag=method_frame.delivery_tag, - requeue=True)) + self.rpc( + commands.Basic.Reject( + delivery_tag=method_frame.delivery_tag, requeue=True + ) + ) @property - def _supports_basic_nack(self): - """Indicates if the server supports Basic.Nack - - :rtype: bool - - """ + def _supports_basic_nack(self) -> bool: + """Indicates if the server supports `Basic.Nack`""" return self._server_capabilities.get('basic.nack', False) @property - def _supports_consumer_cancel_notify(self): # pylint: disable=invalid-name + def _supports_consumer_cancel_notify(self) -> bool: """Indicates if the server supports sending consumer cancellation notifications - :rtype: bool - """ return self._server_capabilities.get('consumer_cancel_notify', False) @property - def _supports_consumer_priorities(self): - """Indicates if the server supports consumer priorities - - :rtype: bool - - """ + def _supports_consumer_priorities(self) -> bool: + """Indicates if the server supports consumer priorities""" return self._server_capabilities.get('consumer_priorities', False) @property - def _supports_per_consumer_qos(self): - """Indicates if the server supports per consumer qos - - :rtype: bool - - """ + def _supports_per_consumer_qos(self) -> bool: + """Indicates if the server supports per consumer qos""" return self._server_capabilities.get('per_consumer_qos', False) @property - def _supports_publisher_confirms(self): - """Indicates if the server supports publisher confirmations - - :rtype: bool - - """ + def _supports_publisher_confirms(self) -> bool: + """Indicates if the server supports publisher confirmations""" return self._server_capabilities.get('publisher_confirms', False) - def _wait_for_content_frames(self, method_frame): + def _wait_for_content_frames( + self, + method_frame: commands.Basic.Deliver + | commands.Basic.Get + | commands.Basic.Return, + ) -> message.Message | None: """Used by both Channel._get_message and Channel._consume_message for getting a message parts off the queue and returning the fully constructed message. :param method_frame: The method frame for the message - :type method_frame: Basic.Deliver or Basic.Get or Basic.Return - :rtype: rabbitpy.Message """ if self.closing or self.closed: return None - consuming = isinstance(method_frame, spec.Basic.Deliver) - if consuming and not self._consumers: + consuming = isinstance(method_frame, commands.Basic.Deliver) + if consuming and not self.consumers: return None if self._is_debugging: - LOGGER.debug('Waiting on content frames for %s: %r', - method_frame.name, method_frame.delivery_tag) + LOGGER.debug( + 'Waiting on content frames for %s: %r', + method_frame.name, + method_frame.delivery_tag, + ) header_value = self._wait_on_frame(CONTENT_HEADER) if not header_value: @@ -474,13 +509,7 @@ def _wait_for_content_frames(self, method_frame): error = False - # To retrieve the message body we must concatenate the binary content - # of several frames. The recommended idiom for this differs - # in py3 and py2. - if PYTHON3: - body_value = bytearray() - else: - body_chunks = [] + body_value = bytearray() body_length_received = 0 body_total_size = header_value.body_size @@ -495,19 +524,13 @@ def _wait_for_content_frames(self, method_frame): break elif self.closing or self.closed: error = True - elif consuming and not self._consumers: + elif consuming and not self.consumers: self._reject_inbound_message(method_frame) error = True if error: return body_length_received += len(body_part.value) - if PYTHON3: - body_value += body_part.value - else: - body_chunks.append(body_part.value) - - if not PYTHON3: - body_value = ''.join(body_chunks) + body_value += body_part.value return self._create_message(method_frame, header_value, body_value) diff --git a/rabbitpy/channel0.py b/rabbitpy/channel0.py index 2e475fe..564f5a1 100644 --- a/rabbitpy/channel0.py +++ b/rabbitpy/channel0.py @@ -1,289 +1,315 @@ """ -Channel0 is used for connection level communication between RabbitMQ and the -client on channel 0. +Channel0 handles AMQP connection negotiation on channel 0. + +After the TCP socket is connected, a Channel0 thread is started which sends +the AMQP ProtocolHeader and completes the Connection.Start/Tune/Open handshake. +Once negotiation succeeds the CHANNEL0_OPENED event is set. On failure an +exception is placed in the shared exceptions queue. """ -import locale + import logging +import queue import sys +import threading +import typing -from pamqp import header -from pamqp import heartbeat -from pamqp import specification +import pamqp.frame +import pamqp.heartbeat +from pamqp import commands, header -from rabbitpy import __version__ -from rabbitpy import base -from rabbitpy import events -from rabbitpy import exceptions -from rabbitpy.utils import queue +from rabbitpy import __version__, exceptions +from rabbitpy import events as ev_module + +if typing.TYPE_CHECKING: + from rabbitpy import io as io_module + from rabbitpy.url_parser import ConnectionArgs LOGGER = logging.getLogger(__name__) -DEFAULT_LOCALE = locale.getdefaultlocale() -del locale +DEFAULT_LOCALE = 'en_US' -class Channel0(base.AMQPChannel): - """Channel0 is used to negotiate a connection with RabbitMQ and for - processing and dispatching events on channel 0 once connected. - :param dict connection_args: Data required to negotiate the connection - :param events_obj: The shared events coordination object - :type events_obj: rabbitpy.events.Events - :param exception_queue: The queue where any pending exceptions live - :type exception_queue: queue.Queue - :param write_queue: The queue to place data to write in - :type write_queue: queue.Queue - :param write_trigger: The socket to write to, to trigger IO writes - :type write_trigger: socket.socket +class Channel0(threading.Thread): + """Negotiate and maintain the AMQP connection on channel 0. - """ - CHANNEL = 0 - - CLOSE_REQUEST_FRAME = specification.Connection.Close - DEFAULT_LOCALE = 'en-US' - - def __init__(self, connection_args, events_obj, exception_queue, - write_queue, write_trigger, connection): - super(Channel0, self).__init__( - exception_queue, write_trigger, connection) - self._channel_id = 0 - self._args = connection_args - self._events = events_obj - self._exceptions = exception_queue - self._read_queue = queue.Queue() - self._write_queue = write_queue - self._write_trigger = write_trigger - self._state = self.CLOSED - self._max_channels = connection_args['channel_max'] - self._max_frame_size = connection_args['frame_max'] - self._heartbeat_interval = connection_args['heartbeat'] - self.properties = None - - def close(self): - """Close the connection via Channel0 communication.""" - if self.open: - self._set_state(self.CLOSING) - self.rpc(specification.Connection.Close()) + :param args: Parsed connection arguments (from url_parser.parse()) + :param events: Shared Events object for cross-thread signalling + :param exceptions_queue: Queue for propagating exceptions to the main thread - @property - def heartbeat_interval(self): - """Return the AMQP heartbeat interval for the connection + """ - :rtype: int + def __init__( + self, + args: 'ConnectionArgs', + events: ev_module.Events, + exceptions_queue: queue.Queue, + ) -> None: + super().__init__(daemon=True, name='Channel0') + self.pending_frames: queue.Queue[pamqp.frame.FrameTypes | None] = ( + queue.Queue() + ) + self._args = args + self._events = events + self._exceptions = exceptions_queue + self._io: io_module.IO | None = None + self._open = False + self._properties: dict = {} + self._max_channels: int = args.get('channel_max', 65535) + self._max_frame_size: int = args.get('frame_max', 131072) + self._heartbeat_interval: int = args.get('heartbeat', 60) + + # ------------------------------------------------------------------------- + # Public interface + # ------------------------------------------------------------------------- - """ + @property + def heartbeat_interval(self) -> int: + """Return the negotiated AMQP heartbeat interval in seconds.""" return self._heartbeat_interval @property - def maximum_channels(self): - """Return the AMQP maximum channel count for the connection - - :rtype: int - - """ + def maximum_channels(self) -> int: + """Return the maximum number of channels for the connection.""" return self._max_channels @property - def maximum_frame_size(self): - """Return the AMQP maximum frame size for the connection - - :rtype: int - - """ + def maximum_frame_size(self) -> int: + """Return the maximum AMQP frame size in bytes.""" return self._max_frame_size - def on_frame(self, value): - """Process a RPC frame received from the server - - :param pamqp.message.Message value: The message value - - """ - LOGGER.debug('Received frame: %r', value.name) - if value.name == 'Connection.Close': - LOGGER.warning('RabbitMQ closed the connection (%s): %s', - value.reply_code, value.reply_text) - self._set_state(self.CLOSED) - self._events.set(events.SOCKET_CLOSED) - self._events.set(events.CHANNEL0_CLOSED) - self._connection.close() - if value.reply_code in exceptions.AMQP: - err = exceptions.AMQP[value.reply_code](value.reply_text) - else: - err = exceptions.RemoteClosedException(value.reply_code, - value.reply_text) - self._exceptions.put(err) - self._trigger_write() - elif value.name == 'Connection.Blocked': - LOGGER.warning('RabbitMQ has blocked the connection: %s', - value.reason) - self._events.set(events.CONNECTION_BLOCKED) - elif value.name == 'Connection.CloseOk': - self._set_state(self.CLOSED) - self._events.set(events.CHANNEL0_CLOSED) - elif value.name == 'Connection.OpenOk': - self._on_connection_open_ok() - elif value.name == 'Connection.Start': - self._on_connection_start(value) - elif value.name == 'Connection.Tune': - self._on_connection_tune(value) - elif value.name == 'Connection.Unblocked': - LOGGER.info('Connection is no longer blocked') - self._events.clear(events.CONNECTION_BLOCKED) - elif value.name == 'Heartbeat': - pass - else: - LOGGER.warning('Unexpected Channel0 Frame: %r', value) - raise specification.AMQPUnexpectedFrame(value) - - def send_heartbeat(self): - """Send a heartbeat frame to the remote connection.""" - self.write_frame(heartbeat.Heartbeat()) - - def start(self): - """Start the AMQP protocol negotiation""" - self._set_state(self.OPENING) - self._write_protocol_header() - - def _build_open_frame(self): - """Build and return the Connection.Open frame. - - :rtype: pamqp.specification.Connection.Open - - """ - return specification.Connection.Open(self._args['virtual_host']) - - def _build_start_ok_frame(self): - """Build and return the Connection.StartOk frame. - - :rtype: pamqp.specification.Connection.StartOk - - """ - properties = { - 'product': 'rabbitpy', - 'platform': 'Python {0}.{1}.{2}'.format(*sys.version_info), - 'capabilities': {'authentication_failure_close': True, - 'basic.nack': True, - 'connection.blocked': True, - 'consumer_cancel_notify': True, - 'publisher_confirms': True}, - 'information': 'See https://rabbitpy.readthedocs.io', - 'version': __version__} - return specification.Connection.StartOk(client_properties=properties, - response=self._credentials, - locale=self._get_locale()) - - def _build_tune_ok_frame(self): - """Build and return the Connection.TuneOk frame. - - :rtype: pamqp.specification.Connection.TuneOk - - """ - return specification.Connection.TuneOk(self._max_channels, - self._max_frame_size, - self._heartbeat_interval) - @property - def _credentials(self): - """Return the marshaled credentials for the AMQP connection. + def open(self) -> bool: + """Return True if the AMQP connection has been fully negotiated.""" + return self._open - :rtype: str + @property + def properties(self) -> dict: + """Return the server properties received in Connection.Start.""" + return self._properties + + def close(self) -> None: + """Send Connection.Close if the connection is currently open.""" + if self._open and self._io is not None: + try: + self._io.write_frame( + 0, + commands.Connection.Close( + reply_code=200, + reply_text='Normal shutdown', + class_id=0, + method_id=0, + ), + ) + except OSError as exc: + LOGGER.debug('Error sending Connection.Close: %r', exc) + self._open = False + + def send_heartbeat(self) -> None: + """Write a heartbeat frame to the server.""" + if self._io is not None: + try: + self._io.write_frame(0, pamqp.heartbeat.Heartbeat()) + except OSError as exc: + LOGGER.debug('Error sending heartbeat: %r', exc) + + def start(self, io: 'io_module.IO') -> None: # type: ignore[override] + """Attach the IO object and start the negotiation thread. + + :param io: The connected IO thread to write frames through """ - return '\0%s\0%s' % (self._args['username'], self._args['password']) - - def _get_locale(self): - """Return the current locale for the python interpreter or the default - locale. - - :rtype: str + self._io = io + super().start() + + # ------------------------------------------------------------------------- + # Thread entry point + # ------------------------------------------------------------------------- + + def run(self) -> None: + """Run the AMQP handshake. Any exception is queued for the caller.""" + try: + self._negotiate() + except BaseException as exc: # noqa: BLE001 + LOGGER.debug('Channel0 negotiation failed: %r', exc) + self._exceptions.put(exc) + self._events.set(ev_module.EXCEPTION_RAISED) + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + + def _negotiate(self) -> None: + """Perform the full AMQP 0-9-1 connection handshake.""" + assert self._io is not None # noqa: S101 + + # Step 1: send the protocol header + self._io.write_frame(0, header.ProtocolHeader()) + + # Step 2: wait for Connection.Start + frame = self._wait_for_frame(timeout=self._args['timeout']) + if not isinstance(frame, commands.Connection.Start): + raise exceptions.ConnectionException( + f'Expected Connection.Start, got {type(frame).__name__}' + ) + self._on_connection_start(frame) + + # Step 3: wait for Connection.Tune + frame = self._wait_for_frame(timeout=self._args['timeout']) + if not isinstance(frame, commands.Connection.Tune): + raise exceptions.ConnectionException( + f'Expected Connection.Tune, got {type(frame).__name__}' + ) + self._on_connection_tune(frame) + + # Step 4: send Connection.Open (TuneOk + Open sent in _on_connection_tune) + frame = self._wait_for_frame(timeout=self._args['timeout']) + if not isinstance(frame, commands.Connection.OpenOk): + raise exceptions.ConnectionException( + f'Expected Connection.OpenOk, got {type(frame).__name__}' + ) + + LOGGER.debug('AMQP connection negotiated') + self._open = True + self._events.set(ev_module.CHANNEL0_OPENED) + + # Step 5: process channel-0 frames for the lifetime of the connection + self._run_loop() + + def _run_loop(self) -> None: + """Process channel-0 management frames (Blocked/Unblocked/Close).""" + while self._open: + try: + frame = self.pending_frames.get(timeout=1.0) + except queue.Empty: + continue + if frame is None: + break + self._handle_runtime_frame(frame) + + def _handle_runtime_frame(self, frame: pamqp.frame.FrameTypes) -> None: + """Dispatch a channel-0 frame received after connection is open.""" + if isinstance(frame, commands.Connection.Blocked): + LOGGER.warning('Connection is blocked: %s', frame.reason) + self._events.set(ev_module.CONNECTION_BLOCKED) + elif isinstance(frame, commands.Connection.Unblocked): + LOGGER.info('Connection is unblocked') + self._events.clear(ev_module.CONNECTION_BLOCKED) + elif isinstance(frame, commands.Connection.Close): + LOGGER.error( + 'Server closed the connection (%s): %s', + frame.reply_code, + frame.reply_text, + ) + self._open = False + exc_cls = exceptions.AMQP.get( + frame.reply_code, exceptions.RemoteClosedException + ) + self._exceptions.put(exc_cls(frame.reply_code, frame.reply_text)) + self._events.set(ev_module.EXCEPTION_RAISED) + + def _wait_for_frame(self, timeout: float) -> pamqp.frame.FrameTypes: + """Block until a frame arrives on pending_frames or timeout expires. + + :param timeout: Maximum seconds to wait + :raises: exceptions.ConnectionException on timeout or IO shutdown """ - if not self._args['locale']: - return DEFAULT_LOCALE[0] or self.DEFAULT_LOCALE - return self._args['locale'] + deadline = timeout * 3 # give extra time for negotiation round-trips + waited = 0.0 + interval = 0.1 + while waited < deadline: + if not self._exceptions.empty(): + raise self._exceptions.get() + try: + frame = self.pending_frames.get(timeout=interval) + if frame is not None: + return frame + except queue.Empty: + pass + waited += interval + + raise exceptions.ConnectionException( + 'Timed out waiting for AMQP server response during negotiation' + ) + + def _on_connection_start(self, frame: commands.Connection.Start) -> None: + """Process Connection.Start and send Connection.StartOk.""" + self._properties = dict(frame.server_properties or {}) + LOGGER.debug('Server properties: %r', self._properties) + self._io.write_frame( # type: ignore[union-attr] + 0, + commands.Connection.StartOk( + client_properties=self._build_client_properties(), + mechanism='PLAIN', + response=f'\0{self._args["username"]}\0{self._args["password"]}', + locale=self._args.get('locale') or DEFAULT_LOCALE, + ), + ) + + def _on_connection_tune(self, frame: commands.Connection.Tune) -> None: + """Process Connection.Tune, send TuneOk, then send Connection.Open.""" + self._max_channels = self._negotiate_value( + frame.channel_max, self._args.get('channel_max', 0) + ) + self._max_frame_size = self._negotiate_value( + frame.frame_max, self._args.get('frame_max', 0) + ) + # Heartbeat: 0 from either side means disabled; otherwise take minimum + server_hb = frame.heartbeat or 0 + client_hb = self._args.get('heartbeat', 60) or 0 + if server_hb == 0 or client_hb == 0: + self._heartbeat_interval = 0 + else: + self._heartbeat_interval = min(server_hb, client_hb) + + LOGGER.debug( + 'Negotiated: channels=%i frame_size=%i heartbeat=%i', + self._max_channels, + self._max_frame_size, + self._heartbeat_interval, + ) + assert self._io is not None # noqa: S101 + self._io.write_frame( + 0, + commands.Connection.TuneOk( + channel_max=self._max_channels, + frame_max=self._max_frame_size, + heartbeat=self._heartbeat_interval, + ), + ) + self._io.write_frame( + 0, + commands.Connection.Open( + virtual_host=self._args['virtual_host'], + ), + ) @staticmethod - def _negotiate(client_value, server_value): - """Return the negotiated value between what the client has requested - and the server has requested for how the two will communicate. - - :param int client_value: - :param int server_value: - :return: int + def _negotiate_value(server_value: int, client_value: int) -> int: + """Return the negotiated value: minimum of both, or either if the + other is zero (meaning 'no preference'). """ - return min(client_value, server_value) or \ - (client_value or server_value) - - def _on_connection_open_ok(self): - LOGGER.debug('Connection opened') - self._set_state(self.OPEN) - self._events.set(events.CHANNEL0_OPENED) - - def _on_connection_start(self, frame_value): - """Negotiate the Connection.Start process, writing out a - Connection.StartOk frame when the Connection.Start frame is received. - - :type frame_value: pamqp.specification.Connection.Start - :raises: rabbitpy.exceptions.ConnectionException - - """ - if not self._validate_connection_start(frame_value): - LOGGER.error('Could not negotiate a connection, disconnecting') - raise exceptions.ConnectionResetException() - - self.properties = frame_value.server_properties - for key in self.properties: - if key == 'capabilities': - for capability in self.properties[key]: - LOGGER.debug('Server supports %s: %r', - capability, self.properties[key][capability]) - else: - LOGGER.debug('Server %s: %r', key, self.properties[key]) - self.write_frame(self._build_start_ok_frame()) - - def _on_connection_tune(self, frame_value): - """Negotiate the Connection.Tune frames, waiting for the - Connection.Tune frame from RabbitMQ and sending the Connection.TuneOk - frame. - - :param specification.Connection.Tune frame_value: Tune frame - - """ - self._max_frame_size = self._negotiate(self._max_frame_size, - frame_value.frame_max) - self._max_channels = self._negotiate(self._max_channels, - frame_value.channel_max) - - LOGGER.debug('Heartbeat interval (server/client): %r/%r', - frame_value.heartbeat, self._heartbeat_interval) - - # Properly negotiate the heartbeat interval - if self._heartbeat_interval is None: - self._heartbeat_interval = frame_value.heartbeat - elif self._heartbeat_interval == 0 or frame_value.heartbeat == 0: - self._heartbeat_interval = 0 - - self.write_frame(self._build_tune_ok_frame()) - self.write_frame(self._build_open_frame()) + if server_value == 0: + return client_value + if client_value == 0: + return server_value + return min(server_value, client_value) @staticmethod - def _validate_connection_start(frame_value): - """Validate the received Connection.Start frame - - :param specification.Connection.Start frame_value: Frame to validate - :rtype: bool - - """ - if (frame_value.version_major, frame_value.version_minor) != \ - (specification.VERSION[0], specification.VERSION[1]): - LOGGER.warning('AMQP version error (received %i.%i, expected %r)', - frame_value.version_major, - frame_value.version_minor, - specification.VERSION) - return False - return True - - def _write_protocol_header(self): - """Send the protocol header to the connected server.""" - self.write_frame(header.ProtocolHeader()) + def _build_client_properties() -> dict: + """Return the client property dict sent in Connection.StartOk.""" + return { + 'product': 'rabbitpy', + 'version': __version__, + 'platform': 'Python {}.{}.{}'.format(*sys.version_info[:3]), + 'information': 'See https://rabbitpy.readthedocs.io', + 'capabilities': { + 'authentication_failure_close': True, + 'basic.nack': True, + 'connection.blocked': True, + 'consumer_cancel_notify': True, + 'publisher_confirms': True, + }, + } diff --git a/rabbitpy/connection.py b/rabbitpy/connection.py index f8bbcc4..910418c 100644 --- a/rabbitpy/connection.py +++ b/rabbitpy/connection.py @@ -1,58 +1,60 @@ -""" -The Connection class negotiates and manages the connection state. - -""" import logging -# pylint: disable=import-error -try: - from urllib import parse as urlparse -except ImportError: - import urlparse - -try: - import ssl -except ImportError: - ssl = None +import queue +import sys import threading import time - -from pamqp import specification as spec - -from rabbitpy import base -from rabbitpy import heartbeat -from rabbitpy import io -from rabbitpy import channel -from rabbitpy import channel0 -from rabbitpy import events -from rabbitpy import exceptions -from rabbitpy import message -from rabbitpy.utils import queue -from rabbitpy import utils +import types +import typing + +from rabbitpy import ( + __version__, + channel0, + events, + exceptions, + io, + state, + url_parser, +) +from rabbitpy import ( + channel as channel_mod, +) +from rabbitpy import ( + heartbeat as heartbeat_mod, +) LOGGER = logging.getLogger(__name__) -AMQP = 'amqp' -AMQPS = 'amqps' - -if ssl: - SSL_CERT_MAP = {'ignore': ssl.CERT_NONE, - 'optional': ssl.CERT_OPTIONAL, - 'required': ssl.CERT_REQUIRED} - SSL_VERSION_MAP = dict() - if hasattr(ssl, 'PROTOCOL_SSLv2'): - SSL_VERSION_MAP['SSLv2'] = getattr(ssl, 'PROTOCOL_SSLv2') - if hasattr(ssl, 'PROTOCOL_SSLv3'): - SSL_VERSION_MAP['SSLv3'] = getattr(ssl, 'PROTOCOL_SSLv3') - if hasattr(ssl, 'PROTOCOL_SSLv23'): - SSL_VERSION_MAP['SSLv23'] = getattr(ssl, 'PROTOCOL_SSLv23') - if hasattr(ssl, 'PROTOCOL_TLSv1'): - SSL_VERSION_MAP['TLSv1'] = getattr(ssl, 'PROTOCOL_TLSv1') -else: - SSL_CERT_MAP, SSL_VERSION_MAP = dict(), dict() - - -# pylint: disable=too-many-instance-attributes -class Connection(base.StatefulObject): + +class ClientProperties: + information = 'See https://rabbitpy.readthedocs.io' + platform = 'Python {0}.{1}.{2}'.format(*sys.version_info) # noqa: UP030 + product = 'rabbitpy' + version = __version__ + + def __init__( + self, additional_properties: dict[str, typing.Any] | None = None + ) -> None: + self._properties: dict[str, typing.Any] = { + 'information': self.information, + 'platform': self.platform, + 'product': self.product, + 'version': self.version, + } + if additional_properties: + self._properties.update(additional_properties) + self._properties['capabilities'] = { + 'authentication_failure_close': True, + 'basic.nack': True, + 'connection.blocked': True, + 'consumer_cancel_notify': True, + 'publisher_confirms': True, + } + + def as_dict(self) -> dict[str, typing.Any]: + return self._properties + + +class Connection(state.StatefulBase): """The Connection object is responsible for negotiating a connection and managing its state. When creating a new instance of the Connection object, if no URL is passed in, it uses the default connection parameters of @@ -66,587 +68,291 @@ class Connection(base.StatefulObject): :code:`[scheme]://[username]:[password]@[host]:[port]/[virtual_host]` - The following example connects to the test virtual host on a RabbitMQ + The following example connects to the `test` virtual host on a RabbitMQ server running at 192.168.1.200 port 5672 as the user "www" and the password rabbitmq: - :code:`amqp://admin192.168.1.200:5672/test` + :code:`amqp://www:rabbitmq@192.168.1.200:5672/test` .. note:: You should be aware that most connection exceptions may be raised during the use of all functionality in the library. - :param str url: The AMQP connection URL - :raises: rabbitpy.exceptions.AMQPException - :raises: rabbitpy.exceptions.ConnectionException - :raises: rabbitpy.exceptions.ConnectionResetException - :raises: rabbitpy.exceptions.RemoteClosedException + :param url: The AMQP connection URL + :param connection_name: An optional name for the connection + :param client_properties: Optional client properties to send + :raises: exceptions.AMQPException + :raises: exceptions.ConnectionException + :raises: exceptions.ConnectionResetException + :raises: exceptions.RemoteClosedException """ - CANCEL_METHOD = ['Basic.Cancel'] - DEFAULT_CHANNEL_MAX = 65535 - DEFAULT_TIMEOUT = 3 - DEFAULT_HEARTBEAT_INTERVAL = 60.0 - DEFAULT_LOCALE = 'en_US' - DEFAULT_URL = 'amqp://guest:guest@localhost:5672/%2F' - DEFAULT_VHOST = '%2F' - GUEST = 'guest' - PORTS = {'amqp': 5672, 'amqps': 5671, 'api': 15672} - - QUEUE_WAIT = 0.01 - - def __init__(self, url=None): - """Create a new instance of the Connection object""" - super(Connection, self).__init__() - - # Create a name for the connection - self._name = '0x%x' % id(self) - - # Extract parts of connection URL for use later - self._args = self._process_url(url or self.DEFAULT_URL) - - # General events and queues shared across threads - self._events = events.Events() - - # A queue for the child threads to put exceptions in - self._exceptions = queue.Queue() - # One queue for writing frames, regardless of the channel sending them - self._write_queue = queue.Queue() + CANCEL_METHOD: typing.ClassVar[list[str]] = ['Basic.Cancel'] - # Lock used when managing the channel stack + def __init__( + self, + url: str | None = None, + connection_name: str | None = None, + client_properties: dict[str, typing.Any] | None = None, + ) -> None: + """Create a new instance of the Connection object""" + super().__init__() + self._args: url_parser.ConnectionArgs = url_parser.parse(url) self._channel_lock = threading.Lock() + self._channel0: channel0.Channel0 | None = None + self._channels: dict[int, channel_mod.Channel] = {} + self._freed_channel_ids: set[int] = set() + self._client_properties = ClientProperties(client_properties) + self._events = events.Events() + self._exceptions: queue.Queue[Exception] = queue.Queue() + self._heartbeat: heartbeat_mod.Heartbeat | None = None + self._io: io.IO | None = None + self._name = connection_name or '0x%x' % id(self) # noqa: UP031 - # Attributes for core object threads - self._channel0 = None - self._channels = dict() - self._heartbeat = None - self._io = None - - # Used by Message for breaking up body frames - self._max_frame_size = None - - # Connect to RabbitMQ - self._connect() - - def __enter__(self): + def __enter__(self) -> typing.Self: """For use as a context manager, return a handle to this object instance. - :rtype: Connection - """ + self.connect() return self - def __exit__(self, exc_type, exc_val, exc_tb): - """When leaving the context, examine why the context is leaving, if - it's an exception or what. - - """ + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ) -> bool | None: + """Close the connection when the context exits""" if exc_type and exc_val: + self._stop_io() + self._set_state(self.CLOSED) + return False # Re-raise the original exception + try: + exc = self._exceptions.get_nowait() + except queue.Empty: + pass + else: + self._stop_io() self._set_state(self.CLOSED) - raise exc_val + raise exc self.close() + return None @property - def args(self): - """Return the connection arguments. - - :rtype: dict - - """ - return dict(self._args) + def args(self) -> url_parser.ConnectionArgs: + """Return the connection arguments.""" + return typing.cast(url_parser.ConnectionArgs, dict(self._args)) @property - def blocked(self): + def blocked(self) -> bool: """Indicates if the connection is blocked from publishing by RabbitMQ. This flag indicates communication from RabbitMQ that the connection is blocked using the Connection.Blocked RPC notification from RabbitMQ that was added in RabbitMQ 3.2. - :rtype: bool - """ - return self._events.is_set(events.CONNECTION_BLOCKED) - - def channel(self, blocking_read=False): - """Create a new channel - - If blocking_read is True, the cross-thread Queue.get use will use - blocking operations that lower resource utilization and increase - throughput. However, due to how Python's blocking Queue.get is - implemented, KeyboardInterrupt is not raised when CTRL-C is - pressed. - - :param bool blocking_read: Enable for higher throughput - :raises: rabbitpy.exceptions.AMQPException - :raises: rabbitpy.exceptions.RemoteClosedChannelException - - """ - with self._channel_lock: - channel_id = self._get_next_channel_id() - channel_frames = queue.Queue() - self._channels[channel_id] = \ - channel.Channel(channel_id, - self.capabilities, - self._events, - self._exceptions, - channel_frames, - self._write_queue, - self._max_frame_size, - self._io.write_trigger, - self, - blocking_read) - self._add_channel_to_io(self._channels[channel_id], channel_frames) - self._channels[channel_id].open() - return self._channels[channel_id] - - def close(self): - """Close the connection, including all open channels. - - :raises: rabbitpy.exceptions.ConnectionClosed - """ - if not self.open and not self.opening: - raise exceptions.ConnectionClosed() - if not self._events.is_set(events.SOCKET_CLOSED): - self._set_state(self.CLOSING) - self._shutdown_connection() - LOGGER.debug('Setting to closed') - self._set_state(self.CLOSED) + return bool(self._events.is_set(events.CONNECTION_BLOCKED)) @property - def capabilities(self): - """Return the RabbitMQ Server capabilities from the connection - negotiation process. - - :rtype: dict - - """ - return self._channel0.properties.get('capabilities', dict()) + def capabilities(self) -> dict[str, typing.Any]: + """Return the RabbitMQ server capabilities from negotiation.""" + if self._channel0 is None: + return {} + result: dict[str, typing.Any] = self._channel0.properties.get( + 'capabilities', {} + ) + return result @property - def server_properties(self): - """Return the RabbitMQ Server properties from the connection - negotiation process. + def server_properties(self) -> dict[str, typing.Any]: + """Return the RabbitMQ server properties from negotiation.""" + if self._channel0 is None: + return {} + return typing.cast(dict[str, typing.Any], self._channel0.properties) - :rtype: dict + def channel(self, blocking_read: bool = False) -> channel_mod.Channel: + """Create and return a new AMQP channel. - """ - return self._channel0.properties + If ``blocking_read`` is ``True``, :py:meth:`Queue.get` calls within + the channel use blocking operations which lower CPU usage and increase + throughput. However, ``KeyboardInterrupt`` will not be raised while + the channel is blocked waiting for a frame. - def _add_channel_to_io(self, channel_id, channel_queue): - """Add a channel and queue to the IO object. - - :param Queue.Queue channel_queue: Channel inbound msg queue - :param rabbitpy.base.AMQPChannel: The channel to add + :param blocking_read: Enable blocking reads for higher throughput + :raises: exceptions.ConnectionClosed + :raises: exceptions.TooManyChannelsError """ - LOGGER.debug('Adding channel %s to io', int(channel_id)) - self._io.add_channel(channel_id, channel_queue) - - @property - def _api_credentials(self): - """Return the auth credentials as a tuple - - @rtype: tuple - - """ - return self._args['username'], self._args['password'] - - @property - def _channel0_closed(self): - """Returns a boolean indicating if the base connection channel (0) - is closed. - - :rtype: bool + if self.closed: + raise exceptions.ConnectionClosed() + with self._channel_lock: + channel_id = self._get_next_channel_id() + channel_frames: queue.Queue[typing.Any] = queue.Queue() + assert self._io is not None # noqa: S101 + assert self._channel0 is not None # noqa: S101 + ch = channel_mod.Channel( + channel_id=channel_id, + server_capabilities=self.capabilities, + events=self._events, + exception_queue=self._exceptions, + read_queue=channel_frames, + write_queue=self._io.write_queue, + maximum_frame_size=self._channel0.maximum_frame_size, + write_trigger=self._io.write_trigger, + connection=self, + blocking_read=blocking_read, + ) + self._channels[channel_id] = ch + self._io.add_channel(channel_id, channel_frames) + ch.open() + return ch + + def close(self) -> None: + """Close the connection to RabbitMQ, including all open channels.""" + self._set_state(self.CLOSING) + self._close_channels() + if self._heartbeat is not None: + self._heartbeat.stop() + self._heartbeat = None + if self._channel0 is not None: + self._channel0.close() + self._events.clear(events.CONNECTION_EVENT) + self._events.clear(events.SOCKET_CLOSE) + self._events.clear(events.SOCKET_CLOSED) + self._events.clear(events.SOCKET_OPENED) + self._stop_io() + self._set_state(self.CLOSED) - """ - return self._channel0.open and not \ - self._events.is_set(events.CHANNEL0_CLOSED) - - def _close_all_channels(self): - """Close all open channels""" - for chan_id in [chan_id for chan_id in self._channels - if not self._channels[chan_id].closed]: - self._channels[chan_id].close() - self._channel0.close() - - def _close_channels(self): - """Close all the channels that are currently open.""" - for channel_id in self._channels: - if (self._channels[channel_id].open and - not self._channels[channel_id].closing): - self._channels[channel_id].close() - - def _connect(self): - """Connect to the RabbitMQ Server""" + def connect(self) -> None: + """Connect to RabbitMQ and negotiate the AMQP connection.""" self._set_state(self.OPENING) - # Create and start the IO object that reads, writes & dispatches frames - self._io = self._create_io_thread() - self._io.daemon = True + + self._channel0 = channel0.Channel0( + args=self._args, + events=self._events, + exceptions_queue=self._exceptions, + ) + + self._io = io.IO( + self._args['host'], + self._args['port'], + self._args['ssl'], + self._args['ssl_options'], + self._events, + self._exceptions, + self._args['timeout'], + ) + self._io.add_channel(0, self._channel0.pending_frames) self._io.start() - # Wait for IO to connect to the socket or raise an exception - while self.opening and not self._events.is_set(events.SOCKET_OPENED): + # Wait for the TCP socket to open + socket_opened = events.SOCKET_OPENED + while self.is_opening and not self._events.is_set(socket_opened): if not self._exceptions.empty(): exception = self._exceptions.get() raise exception - # :meth:`threading.html#threading.Event.wait` always returns None - # in 2.6, so it is impossible to simply check wait() result - self._events.wait(events.SOCKET_OPENED, self._args['timeout']) - if not self._events.is_set(events.SOCKET_OPENED): - raise RuntimeError("Timeout waiting for opening the socket") + self._events.wait(socket_opened, self._args['timeout']) + if not self._events.is_set(socket_opened): + raise RuntimeError('Timeout waiting for opening the socket') - # If the socket could not be opened, return instead of waiting - if self.closed: + # If the socket could not be opened, clean up and return + if self.is_closed: return self.close() - # Create the Channel0 queue and add it to the IO thread - self._channel0 = self._create_channel0() - self._add_channel_to_io(self._channel0, None) - self._channel0.start() + # Start AMQP channel-0 negotiation in its own thread + self._channel0.start(self._io) - # Wait for Channel0 to raise an exception or negotiate the connection - timeout_start = time.time() - while time.time() < timeout_start + self.DEFAULT_TIMEOUT: - - if self._channel0.open: + # Wait for the AMQP handshake to complete (#133, #124: proper timeout + # prevents indefinite hang when the server comes up mid-retry or + # returns an error such as an invalid vhost) + negotiation_timeout = max(self._args['timeout'] * 5, 15) + deadline = time.monotonic() + negotiation_timeout + while time.monotonic() < deadline: + if self._events.is_set(events.CHANNEL0_OPENED): break - if not self._exceptions.empty(): exception = self._exceptions.get() - self._io.stop() + self._stop_io() + self._set_state(self.CLOSED) raise exception - time.sleep(0.01) + else: + self._stop_io() + self._set_state(self.CLOSED) + raise RuntimeError( + 'Timeout waiting for AMQP connection negotiation' + ) + + # Start the heartbeat keeper if the server requires it + if self._channel0.heartbeat_interval: + self._heartbeat = heartbeat_mod.Heartbeat( + self._io, + self._channel0, + self._channel0.heartbeat_interval, + ) + self._heartbeat.start() - # Raise exception if channel not opened before timeout - if not self._channel0.open: - raise exceptions.ConnectionException - - # Set the maximum frame size for channel use - self._max_frame_size = self._channel0.maximum_frame_size - - # Create the heartbeat checker - self._heartbeat = heartbeat.Heartbeat(self._io, self._channel0, - self._args['heartbeat']) - self._heartbeat.start() self._set_state(self.OPEN) - def _create_channel0(self): - """Each connection should have a distinct channel0 + # ------------------------------------------------------------------------- + # Private helpers + # ------------------------------------------------------------------------- - :rtype: rabbitpy.channel0.Channel0 + def _close_channels(self) -> None: + """Close all open channels.""" + for ch in list(self._channels.values()): + if not ch.closed: + try: + ch.close() + except OSError as exc: + LOGGER.debug('Error closing channel: %r', exc) - """ - return channel0.Channel0(connection_args=self._args, - events_obj=self._events, - exception_queue=self._exceptions, - write_queue=self._write_queue, - write_trigger=self._io.write_trigger, - connection=self) - - def _create_io_thread(self): - """Create the IO thread and the objects it uses for communication. + def _get_next_channel_id(self) -> int: + """Return the next available channel ID. - :rtype: rabbitpy.io.IO - - """ - return io.IO(name='%s-io' % self._name, - kwargs={'events': self._events, - 'exceptions': self._exceptions, - 'connection_args': self._args, - 'write_queue': self._write_queue}) - - def _create_message(self, channel_id, method_frame, header_frame, body): - """Create a message instance with the channel it was received on and - the dictionary of message parts. - - :param int channel_id: The channel id the message was sent on - :param method_frame: The method frame value - :type method_frame: pamqp.specification.Frame - :param header_frame: The header frame value - :type header_frame: pamqp.header.ContentHeader - :param str body: The message body - :rtype: rabbitpy.message.Message + Reuses IDs from previously closed channels before allocating a new + one, preventing exhaustion of the 65 535-channel limit on long-lived + connections (#121). Must be called with ``_channel_lock`` held. """ - msg = message.Message(self._channels[channel_id], - body, - header_frame.properties.to_dict()) - msg.method = method_frame - msg.name = method_frame.name - return msg + # Reclaim IDs from channels that have been closed since last call + for channel_id, ch in list(self._channels.items()): + if ch.closed: + del self._channels[channel_id] + self._freed_channel_ids.add(channel_id) - def _get_next_channel_id(self): - """Return the next channel id + if self._freed_channel_ids: + return self._freed_channel_ids.pop() - :rtype: int - - """ if not self._channels: return 1 - if self._max_channel_id == self._channel0.maximum_channels: - raise exceptions.TooManyChannelsError - return self._max_channel_id + 1 - - @property - def _max_channel_id(self): - """Return the maximum channel ID that is currently being used. - - :rtype: int - - """ - return max(list(self._channels.keys())) - @staticmethod - def _normalize_expectations(channel_id, expectations): - """Turn a class or list of classes into a list of class names. - - :param int channel_id: The channel to normalize for - :param expectations: List of classes or class name or class obj - :type expectations: list or str or pamqp.specification.Frame - :rtype: list - - """ - if isinstance(expectations, list): - output = list() - for value in expectations: - if isinstance(value, str): - output.append('%i:%s' % (channel_id, value)) - else: - output.append('%i:%s' % (channel_id, value.name)) - return output - elif utils.is_string(expectations): - return ['%i:%s' % (channel_id, expectations)] - return ['%i:%s' % (channel_id, expectations.name)] - - def _process_url(self, url): - """Parse the AMQP URL passed in and return the configuration - information in a dictionary of values. - - The URL format is as follows: - - amqp[s]://username:password@host:port/virtual_host[?query string] - - Values in the URL such as the virtual_host should be URL encoded or - quoted just as a URL would be in a web browser. The default virtual - host / in RabbitMQ should be passed as %2F. - - Default values: - - - If port is omitted, port 5762 is used for AMQP and port 5671 is - used for AMQPS - - If username or password is omitted, the default value is guest - - If the virtual host is omitted, the default value of %2F is used - - Query string options: - - - heartbeat - - channel_max - - frame_max - - locale - - cacertfile - Path to CA certificate file - - certfile - Path to client certificate file - - keyfile - Path to client certificate key - - verify - Server certificate validation requirements (1) - - ssl_version - SSL version to use (2) - - (1) Should be one of three values: - - - ignore - Ignore the cert if provided (default) - - optional - Cert is validated if provided - - required - Cert is required and validated - - (2) Should be one of four values: - - - SSLv2 - - SSLv3 - - SSLv23 - - TLSv1 - - :param str url: The AMQP url passed in - :rtype: dict - :raises: ValueError - - """ - parsed = utils.urlparse(url) - - self._validate_uri_scheme(parsed.scheme) - - # Toggle the SSL flag based upon the URL scheme and if SSL is enabled - use_ssl = True if parsed.scheme == 'amqps' and ssl else False - - # Ensure that SSL is available if SSL is requested - if parsed.scheme == 'amqps' and not ssl: - LOGGER.warning('SSL requested but not available, disabling') - - # Figure out the port as specified by the scheme - scheme_port = self.PORTS[AMQPS] if parsed.scheme == AMQPS \ - else self.PORTS[AMQP] - - # Set the vhost to be after the base slash if it was specified - vhost = self.DEFAULT_VHOST - if parsed.path: - vhost = parsed.path[1:] or self.DEFAULT_VHOST - - # Parse the query string - qargs = utils.parse_qs(parsed.query) - - # Return the configuration dictionary to use when connecting - return { - 'host': parsed.hostname, - 'port': parsed.port or scheme_port, - 'virtual_host': utils.unquote(vhost), - 'username': urlparse.unquote(parsed.username or self.GUEST), - 'password': urlparse.unquote(parsed.password or self.GUEST), - 'timeout': self._qargs_int('timeout', qargs, self.DEFAULT_TIMEOUT), - 'heartbeat': self._qargs_int('heartbeat', qargs, - self.DEFAULT_HEARTBEAT_INTERVAL), - 'frame_max': self._qargs_int('frame_max', qargs, - spec.FRAME_MAX_SIZE), - 'channel_max': self._qargs_int('channel_max', qargs, - self.DEFAULT_CHANNEL_MAX), - 'locale': self._qargs_value('locale', qargs), - 'ssl': use_ssl, - 'cacertfile': self._qargs_mk_value(['cacertfile', 'ssl_cacert'], - qargs), - 'certfile': self._qargs_mk_value(['certfile', 'ssl_cert'], qargs), - 'keyfile': self._qargs_mk_value(['keyfile', 'ssl_key'], qargs), - 'verify': self._qargs_ssl_validation(qargs), - 'ssl_version': self._qargs_ssl_version(qargs)} - - @staticmethod - def _qargs_int(key, values, default): - """Return the query arg value as an integer for the specified key or - return the specified default value. - - :param str key: The key to return the value for - :param dict values: The query value dict returned by urlparse - :param int default: The default return value - :rtype: int - - """ - return int(values.get(key, [default])[0]) - - @staticmethod - def _qargs_float(key, values, default): - """Return the query arg value as a float for the specified key or - return the specified default value. - - :param str key: The key to return the value for - :param dict values: The query value dict returned by urlparse - :param float default: The default return value - :rtype: float - - """ - return float(values.get(key, [default])[0]) - - def _qargs_ssl_validation(self, values): - """Return the value mapped from the string value in the query string - for the AMQP URL specifying which level of server certificate - validation is required, if any. - - :param dict values: The dict of query values from the AMQP URI - :rtype: int - - """ - validation = self._qargs_mk_value(['verify', 'ssl_validation'], values) - if not validation: - return - elif validation not in SSL_CERT_MAP: - raise ValueError( - 'Unsupported server cert validation option: %s', - validation) - return SSL_CERT_MAP[validation] + assert self._channel0 is not None # noqa: S101 + next_id = max(self._channels.keys()) + 1 + if next_id > self._channel0.maximum_channels: + raise exceptions.TooManyChannelsError + return next_id - def _qargs_ssl_version(self, values): - """Return the value mapped from the string value in the query string - for the AMQP URL for SSL version. + def _stop_io(self) -> None: + """Signal the IO thread to stop and wait for it to finish. - :param dict values: The dict of query values from the AMQP URI - :rtype: int + Blocks until the IO thread has exited (or until the connection timeout + elapses) so that callers can rely on the socket being fully torn down + before they transition to CLOSED. The join is skipped when called from + inside the IO thread itself to prevent a deadlock. """ - version = self._qargs_value('ssl_version', values) - if not version: + io_thread = self._io + if io_thread is None: return - elif version not in SSL_VERSION_MAP: - raise ValueError('Unuspported SSL version: %s' % version) - return SSL_VERSION_MAP[version] - - @staticmethod - def _qargs_value(key, values, default=None): - """Return the value from the query arguments for the specified key - or the default value. - - :param str key: The key to get the value for - :param dict values: The query value dict returned by urlparse - :return: mixed - - """ - return values.get(key, [default])[0] - - def _qargs_mk_value(self, keys, values): - """Try and find the query string value where the value can be specified - with different keys. - - :param lists keys: The keys to check - :param dict values: The query value dict returned by urlparse - :return: mixed - - """ - for key in keys: - value = self._qargs_value(key, values) - if value is not None: - return value - return None - - def _shutdown_connection(self): - """Tell Channel0 and IO to stop if they are not stopped. - - """ - # Make sure the heartbeat is not running - if self._heartbeat is not None: - self._heartbeat.stop() - - if not self._events.is_set(events.SOCKET_CLOSED): - self._close_all_channels() - - # Let the IOLoop know to close - self._events.set(events.SOCKET_CLOSE) - - # Break out of select waiting - self._trigger_write() - - if (self._events.is_set(events.SOCKET_OPENED) and - not self._events.is_set(events.SOCKET_CLOSED)): - LOGGER.debug('Waiting on socket to close') - self._events.wait(events.SOCKET_CLOSED, 0.1) - - self._io.stop() - else: - return self._io.stop() - - while self._io.is_alive(): - time.sleep(0.1) - - def _trigger_write(self): - """Notifies the IO loop we need to write a frame by writing a byte - to a local socket. - - """ - utils.trigger_write(self._io.write_trigger) - - def _validate_uri_scheme(self, scheme): - """Insure that the specified URI scheme is supported by rabbitpy - - :param str scheme: The value to validate - :raises: ValueError - - """ - if scheme not in list(self.PORTS.keys()): - raise ValueError('Unsupported URI scheme: %s' % scheme) + io_thread.stop() + if ( + io_thread.is_alive() + and io_thread is not threading.current_thread() + ): + io_thread.join(self._args['timeout']) diff --git a/rabbitpy/events.py b/rabbitpy/events.py index ea0b59b..4203c75 100644 --- a/rabbitpy/events.py +++ b/rabbitpy/events.py @@ -1,7 +1,8 @@ """ -Common rabbitpy events +A high-level wrapper for using threading.Event objects to signal state changes """ + import logging import threading @@ -17,59 +18,61 @@ SOCKET_CLOSED = 0x08 SOCKET_OPENED = 0x09 -DESCRIPTIONS = {0x01: 'Channel 0 Close Requested', - 0x02: 'Channel 0 Closed', - 0x03: 'Channel 0 Opened', - 0x04: 'Connection is blocked', - 0x05: 'Connection Event Occurred', - 0x06: 'Exception Raised', - 0x07: 'Socket Close Requested', - 0x08: 'Socket Closed', - 0x09: 'Socket Connected'} +DESCRIPTIONS = { + 0x01: 'Channel 0 Close Requested', + 0x02: 'Channel 0 Closed', + 0x03: 'Channel 0 Opened', + 0x04: 'Connection is blocked', + 0x05: 'Connection Event Occurred', + 0x06: 'Exception Raised', + 0x07: 'Socket Close Requested', + 0x08: 'Socket Closed', + 0x09: 'Socket Connected', +} -def description(event_id): +def description(event_id: int) -> str: """Return the text description for an event""" - return DESCRIPTIONS.get(event_id, event_id) + return DESCRIPTIONS.get(event_id, str(event_id)) -class Events(object): +class Events: """All events that get triggered in rabbitpy are funneled through this object for a common structure and method for raising and checking for them. """ - def __init__(self): + + def __init__(self) -> None: """Create a new instance of Events""" self._events = self._create_event_objects() @staticmethod - def _create_event_objects(): + def _create_event_objects() -> dict[int, threading.Event]: """Events are used like signals across threads for communicating state changes, used by the various threaded objects to communicate with each other when an action needs to be taken. - :rtype: dict - """ - events = dict() - for event in [CHANNEL0_CLOSE, - CHANNEL0_CLOSED, - CHANNEL0_OPENED, - CONNECTION_BLOCKED, - CONNECTION_EVENT, - EXCEPTION_RAISED, - SOCKET_CLOSE, - SOCKET_CLOSED, - SOCKET_OPENED]: + events = {} + for event in [ + CHANNEL0_CLOSE, + CHANNEL0_CLOSED, + CHANNEL0_OPENED, + CONNECTION_BLOCKED, + CONNECTION_EVENT, + EXCEPTION_RAISED, + SOCKET_CLOSE, + SOCKET_CLOSED, + SOCKET_OPENED, + ]: events[event] = threading.Event() return events - def clear(self, event_id): + def clear(self, event_id: int) -> bool | None: """Clear a set event, returning bool indicating success and None for an invalid event. - :param int event_id: The event to set - :rtype: bool + :param event_id: The event to clear """ if event_id not in self._events: @@ -83,12 +86,11 @@ def clear(self, event_id): self._events[event_id].clear() return True - def is_set(self, event_id): + def is_set(self, event_id: int) -> bool | None: """Check if an event is triggered. Returns bool indicating state of the event being set. If the event is invalid, a None is returned instead. - :param int event_id: The event to fire - :rtype: bool + :param event_id: The event to fire """ if event_id not in self._events: @@ -96,12 +98,11 @@ def is_set(self, event_id): return None return self._events[event_id].is_set() - def set(self, event_id): + def set(self, event_id: int) -> bool | None: """Trigger an event to fire. Returns bool indicating success in firing the event. If the event is not valid, return None. - :param int event_id: The event to fire - :rtype: bool + :param event_id: The event to fire """ if event_id not in self._events: @@ -115,19 +116,22 @@ def set(self, event_id): self._events[event_id].set() return True - def wait(self, event_id, timeout=1): + def wait(self, event_id: int, timeout: float = 1) -> bool | None: """Wait for an event to be set for up to `timeout` seconds. If `timeout` is None, block until the event is set. If the event is invalid, None will be returned, otherwise False is used to indicate the event is still not set when using a timeout. - :param int event_id: The event to wait for - :param float timeout: The number of seconds to wait + :param event_id: The event to wait for + :param timeout: The number of seconds to wait """ if event_id not in self._events: LOGGER.debug('Event does not exist: %s', description(event_id)) return None - LOGGER.debug('Waiting for %i seconds on event: %s', - timeout, description(event_id)) + LOGGER.debug( + 'Waiting for %i seconds on event: %s', + timeout, + description(event_id), + ) return self._events[event_id].wait(timeout) diff --git a/rabbitpy/exceptions.py b/rabbitpy/exceptions.py index c35d2e5..90425cb 100644 --- a/rabbitpy/exceptions.py +++ b/rabbitpy/exceptions.py @@ -6,12 +6,14 @@ class RabbitpyException(Exception): - """Base exception of all rabbitpy exceptions.""" + """Base exception for all rabbitpy exceptions.""" + pass class AMQPException(RabbitpyException): - """Base exception of all AMQP exceptions.""" + """Base exception for all AMQP exceptions.""" + pass @@ -22,15 +24,19 @@ class ActionException(RabbitpyException): sent by RabbitMQ via an AMQP Basic.Get or Basic.Consume. """ - def __str__(self): - return self.args[0] + + def __str__(self) -> str: + return str(self.args[0]) class ChannelClosedException(RabbitpyException): """Raised when an action is attempted on a channel that is closed.""" - def __str__(self): - return 'Can not perform RPC requests on a closed channel, you must ' \ - 'create a new channel' + + def __str__(self) -> str: + return ( + 'Can not perform RPC requests on a closed channel, you must ' + 'create a new channel' + ) class ConnectionException(RabbitpyException): @@ -39,8 +45,9 @@ class ConnectionException(RabbitpyException): authentication_failure_close feature added in RabbitMQ 3.2. """ - def __str__(self): - return 'Unable to connect to the remote server {0}'.format(self.args) + + def __str__(self) -> str: + return f'Unable to connect to the remote server {self.args}' class ConnectionClosed(ConnectionException): @@ -48,7 +55,8 @@ class ConnectionClosed(ConnectionException): open. """ - def __str__(self): + + def __str__(self) -> str: return 'The connection is closed' @@ -58,13 +66,15 @@ class ConnectionResetException(ConnectionException): missed heartbeat intervals if heartbeats are enabled. """ - def __str__(self): + + def __str__(self) -> str: return 'Connection was reset at socket level' class RemoteCancellationException(RabbitpyException): """Raised if RabbitMQ cancels an active consumer""" - def __str__(self): + + def __str__(self) -> str: return 'Remote server cancelled the active consumer' @@ -73,9 +83,12 @@ class RemoteClosedChannelException(RabbitpyException): Channel.Close RPC request does not have a mapped exception in Rabbitpy. """ - def __str__(self): - return 'Channel {0} was closed by the remote server ' \ - '({1}): {2}'.format(*self.args) + + def __str__(self) -> str: + return ( + f'Channel {self.args[0]} was closed by the remote server ' + f'({self.args[1]}): {self.args[2]}' + ) class RemoteClosedException(RabbitpyException): @@ -83,9 +96,12 @@ class RemoteClosedException(RabbitpyException): Connection.Close RPC request does not have a mapped exception in Rabbitpy. """ - def __str__(self): - return 'Connection was closed by the remote server ' \ - '({0}): {1}'.format(*self.args) + + def __str__(self) -> str: + return ( + f'Connection was closed by the remote server ' + f'({self.args[0]}): {self.args[1]}' + ) class MessageReturnedException(RabbitpyException): @@ -93,9 +109,12 @@ class MessageReturnedException(RabbitpyException): the Basic.Return RPC call. """ - def __str__(self): - return 'Message was returned by RabbitMQ: ({0}) ' \ - 'for exchange {1}'.format(*self.args) + + def __str__(self) -> str: + return ( + f'Message was returned by RabbitMQ: ({self.args[0]}) ' + f'for exchange {self.args[1]}' + ) class NoActiveTransactionError(RabbitpyException): @@ -103,7 +122,8 @@ class NoActiveTransactionError(RabbitpyException): been initiated. """ - def __str__(self): + + def __str__(self) -> str: return 'No active transaction for the request, channel closed' @@ -112,7 +132,8 @@ class NotConsumingError(RabbitpyException): actively consuming. """ - def __str__(self): + + def __str__(self) -> str: return 'No active consumer to cancel' @@ -121,8 +142,16 @@ class NotSupportedError(RabbitpyException): server. """ - def __str__(self): - return 'The selected feature "{0}" is not supported'.format(self.args) + + def __str__(self) -> str: + return f'The selected feature "{self.args}" is not supported' + + +class ReceivedOnClosedChannelException(RabbitpyException): + """Raised when RabbitMQ sends an RPC on a channel that is closed.""" + + def __str__(self) -> str: + return f'RabbitMQ sent a frame on a closed channel ({self.args[0]})' class TooManyChannelsError(RabbitpyException): @@ -133,7 +162,8 @@ class TooManyChannelsError(RabbitpyException): this exception will be raised. """ - def __str__(self): + + def __str__(self) -> str: return 'The maximum amount of negotiated channels has been reached' @@ -142,9 +172,12 @@ class UnexpectedResponseError(RabbitpyException): back is not recognized. """ - def __str__(self): - return 'Received an expected response, expected {0}, ' \ - 'received {1}'.format(*self.args) + + def __str__(self) -> str: + return ( + f'Received an expected response, expected {self.args[0]}, ' + f'received {self.args[1]}' + ) # AMQP Exceptions @@ -156,6 +189,7 @@ class AMQPContentTooLarge(AMQPException): accept at the present time. The client may retry at a later time. """ + pass @@ -164,6 +198,7 @@ class AMQPNoRoute(AMQPException): Undocumented AMQP Soft Error """ + pass @@ -174,6 +209,7 @@ class AMQPNoConsumers(AMQPException): consumers of the queue. """ + pass @@ -183,6 +219,7 @@ class AMQPAccessRefused(AMQPException): due to security settings. """ + pass @@ -191,6 +228,7 @@ class AMQPNotFound(AMQPException): The client attempted to work with a server entity that does not exist. """ + pass @@ -200,6 +238,7 @@ class AMQPResourceLocked(AMQPException): because another client is working with it. """ + pass @@ -209,6 +248,7 @@ class AMQPPreconditionFailed(AMQPException): precondition failed. """ + pass @@ -218,6 +258,7 @@ class AMQPConnectionForced(AMQPException): may retry at some later date. """ + pass @@ -226,6 +267,7 @@ class AMQPInvalidPath(AMQPException): The client tried to work with an unknown virtual host. """ + pass @@ -235,6 +277,7 @@ class AMQPFrameError(AMQPException): strongly implies a programming error in the sending peer. """ + pass @@ -244,6 +287,7 @@ class AMQPSyntaxError(AMQPException): fields. This strongly implies a programming error in the sending peer. """ + pass @@ -254,6 +298,7 @@ class AMQPCommandInvalid(AMQPException): programming error in the client. """ + pass @@ -263,6 +308,7 @@ class AMQPChannelError(AMQPException): opened. This most likely indicates a fault in the client layer. """ + pass @@ -273,6 +319,7 @@ class AMQPUnexpectedFrame(AMQPException): content processing. """ + pass @@ -283,6 +330,7 @@ class AMQPResourceError(AMQPException): entity. """ + pass @@ -292,6 +340,7 @@ class AMQPNotAllowed(AMQPException): the server, due to security settings or by some other criteria. """ + pass @@ -301,6 +350,7 @@ class AMQPNotImplemented(AMQPException): server. """ + pass @@ -311,24 +361,27 @@ class AMQPInternalError(AMQPException): operations. """ + pass -AMQP = {311: AMQPContentTooLarge, - 312: AMQPNoRoute, - 313: AMQPNoConsumers, - 320: AMQPConnectionForced, - 402: AMQPInvalidPath, - 403: AMQPAccessRefused, - 404: AMQPNotFound, - 405: AMQPResourceLocked, - 406: AMQPPreconditionFailed, - 501: AMQPFrameError, - 502: AMQPSyntaxError, - 503: AMQPCommandInvalid, - 504: AMQPChannelError, - 505: AMQPUnexpectedFrame, - 506: AMQPResourceError, - 530: AMQPNotAllowed, - 540: AMQPNotImplemented, - 541: AMQPInternalError} +AMQP = { + 311: AMQPContentTooLarge, + 312: AMQPNoRoute, + 313: AMQPNoConsumers, + 320: AMQPConnectionForced, + 402: AMQPInvalidPath, + 403: AMQPAccessRefused, + 404: AMQPNotFound, + 405: AMQPResourceLocked, + 406: AMQPPreconditionFailed, + 501: AMQPFrameError, + 502: AMQPSyntaxError, + 503: AMQPCommandInvalid, + 504: AMQPChannelError, + 505: AMQPUnexpectedFrame, + 506: AMQPResourceError, + 530: AMQPNotAllowed, + 540: AMQPNotImplemented, + 541: AMQPInternalError, +} diff --git a/rabbitpy/exchange.py b/rabbitpy/exchange.py index 5b0b3e6..e097bfb 100644 --- a/rabbitpy/exchange.py +++ b/rabbitpy/exchange.py @@ -8,91 +8,124 @@ * :py:class:`TopicExchange` """ + +from __future__ import annotations + import logging -from pamqp import specification +import typing + +from pamqp import commands from rabbitpy import base +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan + LOGGER = logging.getLogger(__name__) +ExchangeTypes = typing.Union[ + str, + '_Exchange', + 'Exchange', + 'DirectExchange', + 'FanoutExchange', + 'HeadersExchange', + 'TopicExchange', +] + class _Exchange(base.AMQPClass): """Exchange class for interacting with an exchange in RabbitMQ including declaration, binding and deletion. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param str exchange_type: The exchange type - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments """ + durable = False - arguments = dict() auto_delete = False type = 'direct' - def __init__(self, channel, name, durable=False, auto_delete=False, - arguments=None): + def __init__( + self, + channel: chan.Channel, + name: str, + durable: bool = False, + auto_delete: bool = False, + arguments: dict | None = None, + ): """Create a new instance of the exchange object.""" - super(_Exchange, self).__init__(channel, name) + super().__init__(channel, name) self.durable = durable self.auto_delete = auto_delete - self.arguments = arguments or dict() + self.arguments = arguments or {} - def bind(self, source, routing_key=None): + def bind( + self, source: ExchangeTypes, routing_key: str | None = None + ) -> None: """Bind to another exchange with the routing key. :param source: The exchange to bind to - :type source: str or :py:class:`rabbitpy.Exchange` - :param str routing_key: The routing key to use + :param routing_key: The routing key to use """ if hasattr(source, 'name'): source = source.name - self._rpc(specification.Exchange.Bind(destination=self.name, - source=source, - routing_key=routing_key)) + self._rpc( + commands.Exchange.Bind( + destination=self.name, source=source, routing_key=routing_key + ) + ) - def declare(self, passive=False): + def declare(self, passive: bool = False) -> None: """Declare the exchange with RabbitMQ. If passive is True and the command arguments do not match, the channel will be closed. - :param bool passive: Do not actually create the exchange + :param passive: Do not actually create the exchange """ - self._rpc(specification.Exchange.Declare(exchange=self.name, - exchange_type=self.type, - durable=self.durable, - passive=passive, - auto_delete=self.auto_delete, - arguments=self.arguments)) - - def delete(self, if_unused=False): + self._rpc( + commands.Exchange.Declare( + exchange=self.name, + exchange_type=self.type, + durable=self.durable, + passive=passive, + auto_delete=self.auto_delete, + arguments=self.arguments, + ) + ) + + def delete(self, if_unused: bool = False) -> None: """Delete the exchange from RabbitMQ. :param bool if_unused: Delete only if unused """ - self._rpc(specification.Exchange.Delete(exchange=self.name, - if_unused=if_unused)) + self._rpc( + commands.Exchange.Delete(exchange=self.name, if_unused=if_unused) + ) - def unbind(self, source, routing_key=None): + def unbind( + self, source: ExchangeTypes, routing_key: str | None = None + ) -> None: """Unbind the exchange from the source exchange with the routing key. If routing key is None, use the queue or exchange name. :param source: The exchange to unbind from - :type source: str or :py:class:`rabbitpy.Exchange` - :param str routing_key: The routing key that binds them + :param routing_key: The routing key that binds them """ if hasattr(source, 'name'): source = source.name - self._rpc(specification.Exchange.Unbind(destination=self.name, - source=source, - routing_key=routing_key)) + self._rpc( + commands.Exchange.Unbind( + destination=self.name, source=source, routing_key=routing_key + ) + ) class Exchange(_Exchange): @@ -100,21 +133,26 @@ class Exchange(_Exchange): declaration, binding and deletion. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param str exchange_type: The exchange type - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :param name: The name of the exchange + :param exchange_type: The exchange type + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments """ - def __init__(self, channel, name, exchange_type='direct', - durable=False, auto_delete=False, - arguments=None): + + def __init__( + self, + channel: chan.Channel, + name: str, + exchange_type: str = 'direct', + durable: bool = False, + auto_delete: bool = False, + arguments: dict | None = None, + ): """Create a new instance of the exchange object.""" self.type = exchange_type - super(Exchange, self).__init__(channel, name, durable, auto_delete, - arguments) + super().__init__(channel, name, durable, auto_delete, arguments) class DirectExchange(_Exchange): @@ -122,13 +160,13 @@ class DirectExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments """ + type = 'direct' @@ -137,28 +175,28 @@ class FanoutExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments """ + type = 'fanout' class HeadersExchange(_Exchange): - """The HeadersExchange class is used for interacting with direct exchanges + """The HeadersExchange class is used for interacting with headers exchanges only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments """ + type = 'headers' @@ -167,11 +205,11 @@ class TopicExchange(_Exchange): only. :param channel: The channel object to communicate on - :type channel: :py:class:`rabbitpy.channel.Channel` - :param str name: The name of the exchange - :param bool durable: Request a durable exchange - :param bool auto_delete: Automatically delete when not in use - :param dict arguments: Optional key/value arguments + :param name: The name of the exchange + :param durable: Request a durable exchange + :param auto_delete: Automatically delete when not in use + :param arguments: Optional key/value arguments """ + type = 'topic' diff --git a/rabbitpy/heartbeat.py b/rabbitpy/heartbeat.py index ff688ce..0439c19 100644 --- a/rabbitpy/heartbeat.py +++ b/rabbitpy/heartbeat.py @@ -3,61 +3,80 @@ configured interval. """ + import logging import threading +from rabbitpy import channel0 as rabbitpy_channel0 +from rabbitpy import io as rabbitpy_io + LOGGER = logging.getLogger(__name__) -class Heartbeat(object): +class Heartbeat: """Send a heartbeat frame every interval if no data has been written. - :param rabbitpy.io.IO io: Used to get the # of bytes written each interval - :param rabbitpy.channel0.Channel channel0: The channel that the heartbeat - is sent over. + :param io: Used to get the # of bytes written each interval + :param channel0: The channel that the heartbeat is sent over. """ - def __init__(self, io, channel0, interval): + def __init__( + self, + io: rabbitpy_io.IO, + channel0: rabbitpy_channel0.Channel0, + interval: float, + ): self._channel0 = channel0 self._interval = float(interval) / 2.0 self._io = io self._last_written = self._io.bytes_written self._lock = threading.Lock() - self._timer = None + self._stopped = False + self._timer: threading.Timer | None = None - def start(self): + def start(self) -> None: """Start the heartbeat checker""" if not self._interval: LOGGER.debug('Heartbeats are disabled, not starting') return - self._start_timer() - LOGGER.debug('Heartbeat started, ensuring data is written at least ' - 'every %.2f seconds', self._interval) + with self._lock: + self._stopped = False + self._start_timer() + LOGGER.debug( + 'Heartbeat started, ensuring data is written every %.2f seconds', + self._interval, + ) - def stop(self): + def stop(self) -> None: """Stop the heartbeat checker""" - if self._timer: - self._timer.cancel() - self._timer = None + with self._lock: + self._stopped = True + if self._timer: + self._timer.cancel() + self._timer = None - def _start_timer(self): + def _start_timer(self) -> None: """Create a new thread timer, destroying the last if it existed.""" if self._timer: - del self._timer + self._timer.cancel() self._timer = threading.Timer(self._interval, self._maybe_send) self._timer.daemon = True self._timer.start() - def _maybe_send(self): + def _maybe_send(self) -> None: """Fired by threading.Timer every ``self._interval`` seconds to maybe send a heartbeat to the remote connection, if no other frames have been written. """ + with self._lock: + if self._stopped: + return if not self._io.bytes_written - self._last_written: self._channel0.send_heartbeat() - self._lock.acquire(True) - self._last_written = self._io.bytes_written - self._lock.release() - self._start_timer() + with self._lock: + if self._stopped: + return + self._last_written = self._io.bytes_written + self._start_timer() diff --git a/rabbitpy/io.py b/rabbitpy/io.py index 2470ca4..7d9f0c9 100644 --- a/rabbitpy/io.py +++ b/rabbitpy/io.py @@ -1,682 +1,444 @@ -""" -Core Threaded IO implementation used to communicate with RabbitMQ at the socket -level. - -""" +import asyncio import collections -import errno import logging -import select +import queue import socket import ssl import threading +import typing -from pamqp import frame -from pamqp import exceptions as pamqp_exceptions -from pamqp import specification +import pamqp.base +import pamqp.body +import pamqp.exceptions +import pamqp.frame +import pamqp.header +import pamqp.heartbeat -from rabbitpy import base -from rabbitpy import events -from rabbitpy import exceptions +import rabbitpy.events +import rabbitpy.exceptions +from rabbitpy.url_parser import SslOptions LOGGER = logging.getLogger(__name__) -MAX_READ = specification.FRAME_MAX_SIZE -MAX_WRITE = specification.FRAME_MAX_SIZE - -# Timeout in seconds -POLL_TIMEOUT = 1.0 - - -class _SelectPoller(object): - def __init__(self, fd, write_trigger): - self.read = [[fd, write_trigger], [], [fd], POLL_TIMEOUT] - self.write = [[fd, write_trigger], [fd], [fd], POLL_TIMEOUT] - - def poll(self, write_wanted): - """Invoke select.select, waiting for it to return with the per action - list of file descriptors that are returned to the IO Loop. - - :param bool write_wanted: Is there data pending to be written - :rtype: tuple(list, list, list) - :return: (read, write, error) - - """ - try: - if write_wanted: - rlist, wlist, xlist = select.select(*self.write) - else: - rlist, wlist, xlist = select.select(*self.read) - except select.error: - return [], [], [] - return ([sock.fileno() for sock in rlist], - [sock.fileno() for sock in wlist], - [sock.fileno() for sock in xlist]) - - -class _KQueuePoller(object): - - MAX_EVENTS = 1000 - - def __init__(self, fd, write_trigger): - self._fd = fd - self._write_trigger = write_trigger - self._kqueue = select.kqueue() - self._kqueue.control([select.kevent(fd, select.KQ_FILTER_READ, - select.KQ_EV_ADD)], 0) - self._kqueue.control([select.kevent(write_trigger, - select.KQ_FILTER_READ, - select.KQ_EV_ADD)], 0) - self._write_in_last_poll = False - - def poll(self, write_wanted): - """Update the KQueue object with the desired actions to block on, - waiting until the KQueue.control method returns events and then returns - the list of actions containing file descriptors to act on for those - actions. - - :param bool write_wanted: Is there data pending to be written - :rtype: tuple(list, list, list) - :return: (read, write, error) - - """ - rlist, wlist, xlist = [], [], [] - try: - kq_events = self._kqueue.control(self._changelist(write_wanted), - self.MAX_EVENTS, POLL_TIMEOUT) - except select.error as error: - LOGGER.debug('kqueue.control error: %s', error) - return [], [], [] - for event in kq_events: - if event.filter == select.KQ_FILTER_READ: - rlist.append(event.ident) - elif event.filter == select.KQ_FILTER_WRITE: - wlist.append(event.ident) - if event.flags & select.KQ_EV_ERROR: - xlist.append(event.ident) - if event.flags & select.KQ_EV_EOF == select.KQ_EV_EOF: - xlist.append(event.data) - if xlist: - self._cleanup() - return rlist, wlist, xlist - - def _changelist(self, write_wanted): - if write_wanted and not self._write_in_last_poll: - self._write_in_last_poll = True - return [select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_ADD)] - elif self._write_in_last_poll and not write_wanted: - self._write_in_last_poll = False - return [select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_DELETE)] - return None - - def _cleanup(self): - self._kqueue.control([select.kevent(self._fd, select.KQ_FILTER_READ, - select.KQ_EV_DELETE)], 0) - self._kqueue.control([select.kevent(self._write_trigger, - select.KQ_FILTER_READ, - select.KQ_EV_DELETE)], 0) - if self._write_in_last_poll: - return [select.kevent(self._fd, select.KQ_FILTER_WRITE, - select.KQ_EV_DELETE)] - - -class _PollPoller(object): - - # Register constants to prevent platform specific errors - POLLIN = 1 - POLLOUT = 4 - POLLERR = 8 - - READ = POLLIN | POLLERR - WRITE = POLLIN | POLLOUT | POLLERR - - def __init__(self, fd, write_trigger): - self._fd = fd - self._poll = select.poll() - self._poll.register(fd, self.READ) - self._poll.register(write_trigger, self.READ) - self._write_in_last_poll = False - - def poll(self, write_wanted): - """Update the Poll object with the desired actions to block on, waiting - until the poll returns events and then returns the list of actions - containing file descriptors to act on for those actions. - - :param bool write_wanted: Is there data pending to be written - :rtype: tuple(list, list, list) - :return: (read, write, error) +# Convenience alias: pamqp 4 exposes FrameTypes as the canonical union. +# We extend it with None to represent "no frame decoded yet". +PamqpFrame = pamqp.frame.FrameTypes | None + +AddrInfo = list[ + tuple[socket.AddressFamily, socket.SocketKind, int, str, typing.Any] +] + + +class IO(threading.Thread): + """Handles asynchronous I/O for RabbitMQ connections.""" + + def __init__( + self, + host: str, + port: int, + use_ssl: bool, + ssl_options: SslOptions, + events: rabbitpy.events.Events, + exceptions: queue.Queue[Exception], + timeout: float = 0.01, + ) -> None: + """Initialize the IO thread.""" + super().__init__(daemon=True, name='IO') + self._events = events + self._exceptions = exceptions + self._host = host + self._port = port + self._ssl_options = ssl_options + self._timeout = timeout + self._use_ssl = use_ssl + + self._buffer = b'' + self._closed = asyncio.Event() + self._closed.set() + self._stop_requested = threading.Event() + self._lock = threading.Lock() + self._bytes_read = 0 + self._bytes_written = 0 + self._channels: collections.defaultdict[ + int, queue.Queue[PamqpFrame] + ] = collections.defaultdict(queue.Queue) + self._ioloop: asyncio.AbstractEventLoop | None = None + self._remote_name: str | None = None + self._socket: socket.socket | None = None + self._write_buffer: collections.deque[bytes] = collections.deque() + # Write trigger: a socket pair used by AMQPChannel.write_frame() to + # wake the asyncio event loop when frames are queued for writing. + self._write_trigger_r, self._write_trigger_w = socket.socketpair() + self._write_trigger_r.setblocking(False) + self._write_trigger_w.setblocking(False) + # Shared write queue consumed by _on_write_trigger (channel_id, frame). + self._write_queue: queue.Queue[ + tuple[int, pamqp.base.Frame | pamqp.heartbeat.Heartbeat] + ] = queue.Queue() + + def add_channel( + self, channel: int, read_queue: queue.Queue[PamqpFrame] + ) -> None: + """Associate a channel with a queue for frame dispatching.""" + with self._lock: + self._channels[int(channel)] = read_queue + + def close(self) -> None: + """Close the socket and update event states. + + Called from within the IO thread's finally block after the event loop + exits. Do not call this from outside — use stop() instead. """ - self._update_poll(write_wanted) - rlist, wlist, xlist = [], [], [] - try: - poll_events = self._poll.poll(POLL_TIMEOUT * 1000) - except select.error: - return [], [], [] - for fileno, event in poll_events: - if event & self.POLLIN: - rlist.append(fileno) - if event & self.POLLOUT: - wlist.append(fileno) - if event & self.POLLERR: - xlist.append(fileno) - return rlist, wlist, xlist - - def _update_poll(self, write_wanted): - if self._write_in_last_poll: - if not write_wanted: - self._write_in_last_poll = False - self._poll.modify(self._fd, self.READ) - else: - self._write_in_last_poll = True - self._poll.modify(self._fd, self.WRITE) - - -class _IOLoop(object): - """Generic base IOLoop implementation that leverages different types of - Polling (select, KQueue, poll). - - """ - def __init__(self, fd, error_callback, read_callback, write_callback, - write_queue, event_obj, write_trigger, exception_stack): - self._data = threading.local() - self._data.fd = fd - self._data.error_callback = error_callback - self._data.read_callback = read_callback - self._data.running = False - self._data.ssl = hasattr(fd, 'read') - self._data.events = event_obj - self._data.write_buffer = collections.deque() - self._data.write_callback = write_callback - self._data.write_queue = write_queue - self._data.write_trigger = write_trigger - self._server_sock = None - self._exceptions = exception_stack - self._poller = self._create_poller() - - def run(self): - """Run the IOLoop, blocking until the socket is closed or there is - another exception. + with self._lock: + self._events.clear(rabbitpy.events.SOCKET_OPENED) + self._close_socket() + for sock in (self._write_trigger_r, self._write_trigger_w): + try: + sock.close() + except OSError: + pass + self._closed.set() + self._events.set(rabbitpy.events.SOCKET_CLOSED) + + def stop(self) -> None: + """Signal the I/O loop to stop from outside the IO thread. + + Thread-safe: schedules the close event via the running event loop, or + sets it directly if the loop is not yet running. Also sets + _stop_requested so that a stop() call received during startup (before + _connect() runs) is not silently discarded when _connect() clears + _closed. """ - self._data.running = True - while self._data.running: - try: - self._poll() - except EnvironmentError as exception: - if getattr(exception, 'errno') == errno.EINTR: - continue - elif (isinstance(getattr(exception, 'args'), tuple) and - len(exception.args) == 2 and - exception.args[0] == errno.EINTR): - continue - if self._data.events.is_set(events.SOCKET_CLOSED): - LOGGER.debug('Exiting due to closed socket') - break - elif self._data.events.is_set(events.SOCKET_CLOSE): - LOGGER.debug('Exiting due to closing socket') - self._exceptions.put(exceptions.ConnectionResetException()) - break - LOGGER.debug('Exiting IOLoop.run') - - def stop(self): - """Stop the IOLoop.""" - LOGGER.debug('Stopping IOLoop') - self._data.running = False - try: - self._data.write_trigger.close() - except socket.error: - pass - - def _create_poller(self): - if hasattr(select, 'kqueue'): - LOGGER.debug('Returning KQueuePoller') - return _KQueuePoller(self._data.fd, self._data.write_trigger) - elif hasattr(select, 'poll'): - LOGGER.debug('Returning PollPoller') - return _PollPoller(self._data.fd, self._data.write_trigger) + self._stop_requested.set() + loop = self._ioloop + if loop is not None and loop.is_running(): + loop.call_soon_threadsafe(self._closed.set) else: - LOGGER.debug('Returning SelectPoller') - return _SelectPoller(self._data.fd, self._data.write_trigger) - - def _poll(self): - # Poll select with the materialized lists - if not self._data.running: - LOGGER.debug('Exiting poll') - - # Build the outbound write buffer of marshalled frames - while not self._data.write_queue.empty(): - data = self._data.write_queue.get(False) - self._data.write_buffer.append(frame.marshal(data[1], data[0])) - - # Poll the poller, passing in a bool if there is data to write - rlist, wlist, xlist = self._poller.poll(bool(self._data.write_buffer)) - - if xlist: - LOGGER.debug('Poll errors: %r', xlist) - self._data.events.set(events.SOCKET_CLOSE) - self._data.error_callback('Connection reset') - return - - # Clear out the trigger socket - if self._data.write_trigger.fileno() in rlist: - self._data.write_trigger.recv(1024) - - # Read if the data socket is in the read list - if self._data.fd.fileno() in rlist: - self._read() + self._closed.set() - # Write if the data socket is writable - if wlist and self._data.write_buffer: - self._write() - - def _read(self): - if not self._data.running: - LOGGER.debug('Skipping read, not running') - return + def run(self) -> None: + """Run the I/O loop.""" try: - if self._data.ssl: - self._data.read_callback(self._data.fd.read(MAX_READ)) - else: - self._data.read_callback(self._data.fd.recv(MAX_READ)) - except socket.timeout: - LOGGER.warning('Timed out reading from socket') - except socket.error as exception: - self._data.running = False - self._data.error_callback(exception) - - def _write(self): - if not self._data.running: - LOGGER.debug('Skipping write frame, not running') + asyncio.run(self._run()) + except asyncio.CancelledError: + LOGGER.debug('IO thread cancelled') + except OSError as error: + self._on_error(error) + finally: + self.close() + + def write_frame( + self, channel: int, frame_value: pamqp.frame.FrameTypes + ) -> None: + """Write an AMQP frame to the socket.""" + if not self.is_connected: + self._add_exception( + rabbitpy.exceptions.ConnectionException( + self._host, self._port, 'Not connected' + ) + ) return - - frame_data = self._data.write_buffer.popleft() - try: - bytes_sent = self._data.fd.send(frame_data) - except socket.timeout: - LOGGER.warning('Timed out writing %i bytes to socket', - len(frame_data)) - self._data.write_buffer.appendleft(frame_data) - except socket.error as error: - if error.errno == 35: - LOGGER.debug('socket resource temp unavailable') - self._data.write_buffer.appendleft(frame_data) - else: - self._data.running = False - self._data.error_callback(error) - else: - self._data.write_callback(bytes_sent) - # If the entire frame could not be send, send the rest next time - if bytes_sent < len(frame_data): - self._data.write_buffer.appendleft(frame_data[bytes_sent:]) - - -class IO(threading.Thread, base.StatefulObject): - """IO is the primary IO thread that is responsible for communicating with - RabbitMQ at the socket level and adds demashalled frames to the appropriate - thread-safe data structures. - - """ - CONTENT_METHODS = ['Basic.Deliver', 'Basic.GetOk'] - READ_BUFFER_SIZE = specification.FRAME_MAX_SIZE - SSL_KWARGS = { - 'keyfile': 'keyfile', - 'certfile': 'certfile', - 'cert_reqs': 'verify', - 'ssl_version': 'ssl_version', - 'ca_certs': 'cacertfile' - } - - def __init__(self, - group=None, - target=None, - name=None, - args=(), - kwargs=None): - if kwargs is None: - kwargs = dict() - super(IO, self).__init__(group, target, name, args, kwargs) - - self._args = kwargs['connection_args'] - self._events = kwargs['events'] - self._exceptions = kwargs['exceptions'] - self._write_queue = kwargs['write_queue'] - - # A socket to trigger write interrupts with - self._write_listener, self._write_trigger = self._socketpair() - - self._bytes_read = 0 - self._bytes_written = 0 - - self._buffer = bytes() - self._lock = threading.RLock() - self._channels = dict() - self._remote_name = None - self._socket = None - self._state = None - self._loop = None - - def add_channel(self, channel, write_queue): - """Add a channel to the channel queue dict for dispatching frames - to the channel. - - :param rabbitpy.channel.Channel channel: The channel to add - :param Queue.Queue write_queue: Queue for sending frames to the channel - - """ - self._channels[int(channel)] = channel, write_queue + with self._lock: + payload = pamqp.frame.marshal(frame_value, channel) + self._write_buffer.append(payload) @property - def bytes_received(self): - """Return the number of bytes read/received from RabbitMQ - - :rtype: int - - """ - return self._bytes_read + def bytes_received(self) -> int: + """Return the number of bytes received.""" + with self._lock: + return self._bytes_read @property - def bytes_written(self): - """Return the number of bytes written to RabbitMQ - - :rtype: int - - """ - return self._bytes_written - - def run(self): - """The blocking method to execute the core IO object, that connects - and then blocks on the IOLoop, exiting when the IOLoop stops. - - """ - self._connect() - if not self._socket: - LOGGER.warning('Could not connect to %s:%s', self._args['host'], - self._args['port']) - return - - LOGGER.debug('Socket connected') - - # Create the remote name - local_socket = self._socket.getsockname() - peer_socket = self._socket.getpeername() - self._remote_name = '%s:%s -> %s:%s' % ( - local_socket[0], local_socket[1], peer_socket[0], peer_socket[1]) - self._loop = _IOLoop( - self._socket, self.on_error, self.on_read, self.on_write, - self._write_queue, self._events, self._write_listener, - self._exceptions) - self._loop.run() - if not self._exceptions.empty() and \ - not self._events.is_set(events.EXCEPTION_RAISED): - self._events.set(events.EXCEPTION_RAISED) - LOGGER.debug('Exiting IO.run') - - def on_error(self, exception): - """Common functions when a socket error occurs. Make sure to set closed - and add the exception, and note an exception event. - - :param socket.error exception: The socket error - - """ - LOGGER.critical('In on_error: %r', exception) - if self._events.is_set(events.SOCKET_CLOSED): - return - args = [self._args['host'], self._args['port'], str(exception)] - if self._channels[0][0].open: - self._exceptions.put(exceptions.ConnectionResetException(*args)) - else: - self._exceptions.put(exceptions.ConnectionException(*args)) - self._events.set(events.EXCEPTION_RAISED) - - def on_read(self, data): - """Append the data that is read to the buffer and try and parse - frames out of it. - - :param bytes data: The data that has been read in - - """ - self._buffer += data - - while self._buffer: - - # Read and process data - value = self._read_frame() - - # Increment the byte counter used by the heartbeat timer - self._bytes_read += len(value) - - # Break out if a frame could not be decoded - if self._buffer and value[0] is None: - break - - # LOGGER.debug('Received (%i) %r', value[0], value[1]) - - # If it's channel 0, call the Channel0 directly - if value[0] == 0: - with self._lock: - self._channels[0][0].on_frame(value[1]) - continue - - self._add_frame_to_read_queue(value[0], value[1]) - - def on_write(self, bytes_written): - """Keep track of how many bytes have been written. - - :param int bytes_written: How many bytes were written to the socket. - - """ - self._bytes_written += bytes_written - - def stop(self): - """Stop the IO Layer due to exception or other problem.""" - self._close() + def bytes_written(self) -> int: + """Return the number of bytes written.""" + with self._lock: + return self._bytes_written @property - def write_trigger(self): - """Return the write trigger socket. + def is_connected(self) -> bool: + """Indicate socket connection status.""" + with self._lock: + return not self._closed.is_set() - :rtype: socket.socket - - """ - return self._write_trigger + @property + def remote_name(self) -> str | None: + """Return the remote socket name.""" + with self._lock: + return self._remote_name - def _add_frame_to_read_queue(self, channel_id, frame_value): - """Add the frame to the stack by creating the key value used in - expectations and then add it to the list. + @property + def write_trigger(self) -> socket.socket: + """Return the write end of the trigger socket pair. - :param int channel_id: The channel id the frame was received on - :param frame_value: The frame to add - :type frame_value: :class:`~pamqp.specification.Frame` + AMQPChannel instances send a byte here to wake the event loop after + placing a frame in write_queue. """ - # LOGGER.debug('Adding %s to channel %s', frame_value.name, channel_id) - self._channels[channel_id][1].put(frame_value) - - def _close(self): - """Close the socket and set the proper event states""" - self._events.clear(events.SOCKET_OPENED) - self._set_state(self.CLOSING) - if hasattr(self, '_socket') and self._socket: - self._close_socket(self._socket) - if hasattr(self, '_write_listener') and self._write_listener: - self._close_socket(self._write_listener) - if hasattr(self, '_write_trigger') and self._write_trigger: - self._close_socket(self._write_trigger) - if hasattr(self, '_server_socket') and self._server_socket: - self._close_socket(self._server_sock) - if not self._events.is_set(events.SOCKET_CLOSED): - self._events.set(events.SOCKET_CLOSED) - if not self.closed: - self._set_state(self.CLOSED) + return self._write_trigger_w - @staticmethod - def _close_socket(sock): - try: - sock.shutdown(socket.SHUT_RDWR) - sock.close() - except (OSError, socket.error): - pass - - def _connect_socket(self, sock, address): - """Connect the socket to the specified host and port, setting the - timeout. + @property + def write_queue( + self, + ) -> queue.Queue[tuple[int, pamqp.base.Frame | pamqp.heartbeat.Heartbeat]]: + """Return the shared write queue used by AMQPChannel.write_frame().""" + return self._write_queue - :param socket.socket sock: The socket to connect - :param (str, int) address: The address tuple to connect to + # Internal Methods - """ - LOGGER.debug('Connecting to %r', address) - sock.settimeout(self._args['timeout']) - sock.connect(address) + def _add_exception(self, exc: Exception) -> None: + """Add an exception to the exception queue and signal the event.""" + LOGGER.debug('Adding exception %r', exc) + self._exceptions.put(exc) + self._events.set(rabbitpy.events.EXCEPTION_RAISED) - def _connect(self): - """Connect to the RabbitMQ Server + def _close_socket(self) -> None: + """Close the underlying socket. - :raises: ConnectionException + Must be called with self._lock held. Reader/writer removal from the + event loop is handled inside _run() while the loop is still active. """ - self._set_state(self.OPENING) - sock = None - # pylint: disable=unused-variable - for (address_family, socktype, - proto, cname, sockaddr) in self._get_addr_info(): + if self._socket is not None: try: - sock = self._create_socket(address_family, socktype, proto) - self._connect_socket(sock, sockaddr) - break - except socket.error as error: - LOGGER.debug('Error connecting to %r: %s', sockaddr, error) + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except (AttributeError, OSError): + pass + self._socket = None + + async def _connect(self) -> None: + """Establish a connection to the RabbitMQ server.""" + sock: socket.socket | None = None + for addr_info in self._get_addr_info(): + LOGGER.debug('Attempting to connect to %r', addr_info[4]) + try: + sock = self._create_socket(*addr_info[0:3]) + sock.connect(addr_info[4]) + except OSError as error: + if sock is not None: + sock.close() sock = None + LOGGER.debug('Error connecting to %r: %s', addr_info[4], error) continue + else: + break - if not sock: - args = [self._args['host'], self._args['port'], - 'Could not connect'] - self._exceptions.put(exceptions.ConnectionException(*args)) - self._events.set(events.EXCEPTION_RAISED) + if sock is None: + self._add_exception( + rabbitpy.exceptions.ConnectionException( + self._host, self._port, 'Could not connect' + ) + ) return + if self._stop_requested.is_set(): + sock.close() + return # A stop() arrived during startup; honour it + self._closed.clear() self._socket = sock - self._events.set(events.SOCKET_OPENED) - self._set_state(self.OPEN) - - def _create_socket(self, address_family, socktype, protocol): - """Create the new socket, optionally with SSL support. - - :param int address_family: The address family to use when creating - :param int socktype: The type of socket to create - :param int protocol: The protocol to use - :rtype: socket.socket or ssl.SSLSocket - - """ - sock = socket.socket(address_family, socktype, protocol) - if self._args['ssl']: - kwargs = {'sock': sock, 'server_side': False} - for argv, key in self.SSL_KWARGS.items(): - if self._args[key]: - kwargs[argv] = self._args[key] - LOGGER.debug('Wrapping socket for SSL: %r', kwargs) - context = ssl.SSLContext() - return context.wrap_socket(**kwargs) + self._socket.setblocking(False) + self._socket.settimeout(self._timeout) + self._events.set(rabbitpy.events.SOCKET_OPENED) + + def _create_socket( + self, address_family: int, sock_type: int, protocol: int + ) -> socket.socket | ssl.SSLSocket: + """Create a new socket, optionally wrapped with SSL.""" + sock = socket.socket(address_family, sock_type, protocol) + context = self._get_ssl_context() + if context: + return context.wrap_socket(sock=sock) return sock - def _disconnect_socket(self): - """Close the existing socket connection""" - self._socket.close() - - def _get_addr_info(self): - family = socket.AF_UNSPEC - if not socket.has_ipv6: - family = socket.AF_INET + def _get_addr_info(self) -> AddrInfo: + """Resolve hostname and port to address information.""" + family = socket.AF_UNSPEC if socket.has_ipv6 else socket.AF_INET try: - res = socket.getaddrinfo(self._args['host'], self._args['port'], - family, socket.SOCK_STREAM, 0) - except socket.error as error: - LOGGER.error('Could not resolve %s: %s', self._args['host'], error, - exc_info=True) + res = socket.getaddrinfo( + self._host, self._port, family, socket.SOCK_STREAM, 0 + ) + except OSError as error: + LOGGER.debug('Could not resolve %s: %s', self._host, error) return [] return res + def _get_ssl_context(self) -> ssl.SSLContext | None: + """Create and configure an SSL context if SSL is enabled.""" + if self._use_ssl: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + if self._ssl_options.get('check_hostname') is not None: + ssl_context.check_hostname = self._ssl_options[ + 'check_hostname' + ] + if self._ssl_options.get('cafile') or self._ssl_options.get( + 'capath' + ): + ssl_context.load_verify_locations( + cafile=self._ssl_options.get('cafile'), + capath=self._ssl_options.get('capath'), + ) + else: + ssl_context.load_default_certs(ssl.Purpose.SERVER_AUTH) + certfile = self._ssl_options.get('certfile') + if certfile: + ssl_context.load_cert_chain( + certfile, self._ssl_options.get('keyfile') + ) + verify = self._ssl_options.get('verify') + if verify is not None: + ssl_context.verify_mode = verify + return ssl_context + return None + @staticmethod - def _get_frame_from_str(value): - """Get the pamqp frame from the string value. + def _on_data_received( + value: bytes, + ) -> tuple[bytes, int | None, PamqpFrame, int]: + """Unmarshal a pamqp frame from received socket data. + + :param value: The bytes received from the socket. + :return: Tuple containing remaining bytes, channel ID, + frame, and bytes consumed. - :param str value: The value to parse for an pamqp frame - :return (str, int, pamqp.specification.Frame): Remainder of value, - channel id and - frame value """ if not value: - return value, None, None + return value, None, None, 0 try: - byte_count, channel_id, frame_in = frame.unmarshal(value) - except pamqp_exceptions.UnmarshalingException: - return value, None, None - except specification.AMQPFrameError as error: - LOGGER.error('Failed to demarshal: %r', error, exc_info=True) - LOGGER.debug(value) - return value, None, None - return value[byte_count:], channel_id, frame_in + byte_count, channel_id, frame_in = pamqp.frame.unmarshal(value) + except (pamqp.exceptions.UnmarshalingException, ValueError): + return value, None, None, 0 + return value[byte_count:], channel_id, frame_in, byte_count - def _read_frame(self): - """Read from the buffer and try and get the demarshaled frame. + def _on_write_trigger(self) -> None: + """Drain the trigger socket and marshal all queued frames. - :rtype (int, pamqp.specification.Frame): The channel and frame + Called by the asyncio event loop when a byte arrives on the read end + of the write trigger socket pair, signalling that one or more + ``(channel_id, frame)`` tuples have been placed in ``_write_queue`` by + an AMQPChannel in another thread. """ - self._buffer, chan_id, value = self._get_frame_from_str(self._buffer) - return chan_id, value - - def _remote_close_channel(self, channel_id, frame_value): - """Invoke the on_channel_close code in the specified channel. This will - block the IO loop unless the exception is caught. - - :param int channel_id: The channel to remote close - :param frame_value: The Channel.Close frame - :type frame_value: :class:`~pamqp.specification.Frame` + try: + self._write_trigger_r.recv(4096) + except (BlockingIOError, OSError): + pass + while True: + try: + channel_id, frame_value = self._write_queue.get_nowait() + except queue.Empty: + break + with self._lock: + self._write_buffer.append( + pamqp.frame.marshal(frame_value, channel_id) + ) + + def _on_error(self, exception: OSError) -> None: + """Handle socket errors, add exceptions, and signal events.""" + if self._events.is_set(rabbitpy.events.SOCKET_CLOSED): + return + self._add_exception( + rabbitpy.exceptions.ConnectionException( + self._host, self._port, str(exception) + ) + ) + + def _on_read_ready(self) -> None: + """Read data from the socket and process any complete frames.""" + with self._lock: + if self._socket is None: + return + try: + data = self._socket.recv(32768) + except TimeoutError: + return # Transient; wait for the next readable event + except OSError as error: + self._on_error(error) + self._closed.set() + return + if not data: + # Remote end closed the connection cleanly + self._add_exception( + rabbitpy.exceptions.ConnectionResetException() + ) + self._closed.set() + return + self._buffer += data + while self._buffer: + remaining, channel_id, frame_value, bytes_read = ( + self._on_data_received(self._buffer) + ) + if remaining == self._buffer: + break # Incomplete frame — wait for more data + self._buffer = remaining + self._bytes_read += bytes_read + if channel_id is not None: + self._channels[channel_id].put(frame_value) + + def _on_write_ready(self) -> None: + """Write pending data from the write buffer to the socket.""" + try: + with self._lock: + payload = self._write_buffer.popleft() + except IndexError: + return + if self._socket is None: + return + try: + self._socket.sendall(payload) + except TimeoutError: + LOGGER.warning( + 'Timed out writing %i bytes to socket', len(payload) + ) + with self._lock: + self._write_buffer.appendleft(payload) + except OSError as error: + with self._lock: + self._write_buffer.appendleft(payload) + if error.errno == 35: + LOGGER.debug('socket resource temp unavailable') + else: + self._on_error(error) + else: + with self._lock: + self._bytes_written += len(payload) - """ - self._channels[channel_id][0].on_remote_close(frame_value) + async def _run(self) -> None: + """Manage the I/O loop: connect, read, write, and handle closure.""" + loop = asyncio.get_running_loop() + self._ioloop = loop - def _socketpair(self): - """Return a socket pair regardless of platform. + # Register the write-trigger reader immediately so frames queued + # before the socket is fully open are not lost. + loop.add_reader(self._write_trigger_r, self._on_write_trigger) - :rtype: (socket.socket, socket.socket) + with self._lock: + await self._connect() - """ + if not self._socket: + loop.remove_reader(self._write_trigger_r.fileno()) + return # Could not establish connection + + loop.add_reader(self._socket, self._on_read_ready) + loop.add_writer(self._socket, self._on_write_ready) + local_sock = self._socket.getsockname() + peer_sock = self._socket.getpeername() + self._remote_name = ( + f'{local_sock[0]}:{local_sock[1]} -> {peer_sock[0]}:{peer_sock[1]}' + ) + + await self._closed.wait() + + # Remove readers/writers from inside the event loop (thread-safe) + with self._lock: + if self._socket is not None: + try: + loop.remove_reader(self._socket.fileno()) + loop.remove_writer(self._socket.fileno()) + except (AttributeError, OSError): + pass try: - server, client = socket.socketpair() - except AttributeError: - # Connect in Windows - LOGGER.debug('Falling back to emulated socketpair behavior') - - # Create the listening server socket & bind it to a random port - self._server_sock = socket.socket(socket.AF_INET, - socket.SOCK_STREAM) - self._server_sock.bind(('127.0.0.1', 0)) - - # Get the port for the notifying socket to connect to - port = self._server_sock.getsockname()[1] - - # Create the notifying client socket and connect using a timer - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - def connect(): - """Connect to the client to the server socket pair""" - client.connect(('127.0.0.1', port)) - - timer = threading.Timer(0.01, connect) - timer.start() - - # Have the listening server socket listen and accept the connect - self._server_sock.listen(0) - # pylint: disable=unused-variable - server, _unused = self._server_sock.accept() - - # Don't block on either socket - server.setblocking(0) - client.setblocking(0) - return server, client + loop.remove_reader(self._write_trigger_r.fileno()) + except (AttributeError, OSError): + pass diff --git a/rabbitpy/message.py b/rabbitpy/message.py index 2eea572..1a414d6 100644 --- a/rabbitpy/message.py +++ b/rabbitpy/message.py @@ -5,37 +5,31 @@ acknowledging it. """ + +from __future__ import annotations + import datetime import json import logging import math import time -import pprint +import typing import uuid -from pamqp import body -from pamqp import header -from pamqp import specification - -from rabbitpy import base -from rabbitpy import exceptions -from rabbitpy import utils +from pamqp import body, commands, header -LOGGER = logging.getLogger(__name__) +from rabbitpy import base, exceptions, utils +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan + from rabbitpy import exchange as exc -# Python 2.6 does not have a memoryview object, create dummy for isinstance -try: - _PY_VERSION_CHECK = memoryview(b'foo') -except NameError: - # pylint: disable=too-few-public-methods, redefined-builtin - # pylint: disable=invalid-name, missing-docstring - class memoryview(object): - pass +LOGGER = logging.getLogger(__name__) -class Properties(specification.Basic.Properties): +class Properties(commands.Basic.Properties): """Proxy class for :py:class:`pamqp.specification.Basic.Properties`""" + pass @@ -82,27 +76,26 @@ class Message(base.AMQPClass): is not specified when passing in ``properties``, the current Unix epoch value will be set in the message properties. - .. note:: As of 0.21.0 ``auto_id`` is deprecated in favor of - ``opinionated`` and it will be removed in a future version. As of - 0.22.0 ``opinionated`` is defaulted to ``False``. - :param channel: The channel object for the message object to act upon - :type channel: :py:class:`rabbitpy.channel.Channel` :param body_value: The message body - :type body_value: str|bytes|unicode|memoryview|dict|json - :param dict properties: A dictionary of message properties - :param bool auto_id: Add a message id if no properties were passed in. - :param bool opinionated: Automatically populate properties if True + :param properties: A dictionary of message properties + :param opinionated: Automatically populate properties if True :raises KeyError: Raised when an invalid property is passed in """ + method = None name = 'Message' - def __init__(self, channel, body_value, properties=None, auto_id=False, - opinionated=False): + def __init__( + self, + channel: chan.Channel, + body_value: str | bytes | memoryview | dict | list, + properties: dict | None = None, + opinionated: bool = False, + ): """Create a new instance of the Message object.""" - super(Message, self).__init__(channel, 'Message') + super().__init__(channel, 'Message') # Always have a dict of properties set self.properties = properties or {} @@ -111,13 +104,10 @@ def __init__(self, channel, body_value, properties=None, auto_id=False, if isinstance(body_value, memoryview): self.body = bytes(body_value) else: - # pylint: disable=redefined-variable-type self.body = self._auto_serialize(body_value) # Add a message id if auto_id is not turned off and it is not set - if (opinionated or auto_id) and 'message_id' not in self.properties: - if auto_id: - raise DeprecationWarning('Use opinionated instead of auto_id') + if opinionated and 'message_id' not in self.properties: self._add_auto_message_id() if opinionated: @@ -126,55 +116,47 @@ def __init__(self, channel, body_value, properties=None, auto_id=False, # Enforce datetime timestamps if 'timestamp' in self.properties: - self.properties['timestamp'] = \ - self._as_datetime(self.properties['timestamp']) + self.properties['timestamp'] = self._as_datetime( + self.properties['timestamp'] + ) # Don't let invalid property keys in if self._invalid_properties: - msg = 'Invalid property: %s' % self._invalid_properties[0] - raise KeyError(msg) + raise KeyError(f'Invalid property: {self._invalid_properties[0]}') @property - def delivery_tag(self): + def delivery_tag(self) -> int | None: """Return the delivery tag for a message that was delivered or gotten from RabbitMQ. - :rtype: int or None - """ return self.method.delivery_tag if self.method else None @property - def redelivered(self): + def redelivered(self) -> bool | None: """Indicates if this message may have been delivered before (but not - acknowledged)" - - :rtype: bool or None + acknowledged) """ return self.method.redelivered if self.method else None @property - def routing_key(self): + def routing_key(self) -> str | None: """Return the routing_key for a message that was delivered or gotten from RabbitMQ. - :rtype: int or None - """ return self.method.routing_key if self.method else None @property - def exchange(self): + def exchange(self) -> str | None: """Return the source exchange for a message that was delivered or gotten from RabbitMQ. - :rtype: string or None - """ return self.method.exchange if self.method else None - def ack(self, all_previous=False): + def ack(self, all_previous: bool = False) -> None: """Acknowledge receipt of the message to RabbitMQ. Will raise an ActionException if the message was not received from a broker. @@ -182,76 +164,68 @@ def ack(self, all_previous=False): """ if not self.method: - raise exceptions.ActionException('Can not ack non-received ' - 'message') - basic_ack = specification.Basic.Ack(self.method.delivery_tag, - multiple=all_previous) + raise exceptions.ActionException( + 'Can not ack non-received message' + ) + basic_ack = commands.Basic.Ack( + self.method.delivery_tag, multiple=all_previous + ) self.channel.write_frame(basic_ack) - def json(self): - """Deserialize the message body if it is JSON, returning the value. - - :rtype: any - - """ + def json(self) -> typing.Any: + """Deserialize the message body if it is JSON, returning the value.""" try: return json.loads(self.body) except TypeError: # pragma: no cover return json.loads(self.body.decode('utf-8')) - def nack(self, requeue=False, all_previous=False): + def nack(self, requeue: bool = False, all_previous: bool = False) -> None: """Negatively acknowledge receipt of the message to RabbitMQ. Will raise an ActionException if the message was not received from a broker. - :param bool requeue: Requeue the message - :param bool all_previous: Nack all previous unacked messages up to and - including this one + :param requeue: Requeue the message + :param all_previous: Nack all previous unacked messages up to and + including this one :raises: ActionException """ if not self.method: - raise exceptions.ActionException('Can not nack non-received ' - 'message') - basic_nack = specification.Basic.Nack(self.method.delivery_tag, - requeue=requeue, - multiple=all_previous) + raise exceptions.ActionException( + 'Can not nack non-received message' + ) + basic_nack = commands.Basic.Nack( + self.method.delivery_tag, requeue=requeue, multiple=all_previous + ) self.channel.write_frame(basic_nack) - def pprint(self, properties=False): # pragma: no cover + def pprint(self, properties: bool = False) -> None: # pragma: no cover """Print a formatted representation of the message. - :param bool properties: Include properties in the representation + :param properties: Include properties in the representation """ - print('Exchange: %s\n' % self.method.exchange) - print('Routing Key: %s\n' % self.method.routing_key) if properties: - print('Properties:\n') - pprint.pprint(self.properties) - print('\nBody:\n') - pprint.pprint(self.body) - - def publish(self, exchange, routing_key='', mandatory=False, - immediate=False): + pass + + def publish( + self, + exchange: exc.ExchangeTypes, + routing_key: str = '', + mandatory: bool = False, + immediate: bool = False, + ) -> bool | None: """Publish the message to the exchange with the specified routing key. - In Python 2 if the message is a ``unicode`` value it will be converted - to a ``str`` using ``str.encode('UTF-8')``. If you do not want the - auto-conversion to take place, set the body to a ``str`` or ``bytes`` - value prior to publishing. - - In Python 3 if the message is a ``str`` value it will be converted to + If the message is a ``str`` value it will be converted to a ``bytes`` value using ``bytes(value.encode('UTF-8'))``. If you do not want the auto-conversion to take place, set the body to a ``bytes`` value prior to publishing. :param exchange: The exchange to publish the message to - :type exchange: str or :class:`rabbitpy.Exchange` - :param str routing_key: The routing key to use - :param bool mandatory: Requires the message is published - :param bool immediate: Request immediate delivery - :return: bool or None + :param routing_key: The routing key to use + :param mandatory: Requires the message is published + :param immediate: Request immediate delivery :raises: rabbitpy.exceptions.MessageReturnedException """ @@ -261,21 +235,26 @@ def publish(self, exchange, routing_key='', mandatory=False, # Coerce the body to the proper type payload = utils.maybe_utf8_encode(self.body) - frames = [specification.Basic.Publish(exchange=exchange, - routing_key=routing_key or '', - mandatory=mandatory, - immediate=immediate), - header.ContentHeader(body_size=len(payload), - properties=self._properties)] + frames = [ + commands.Basic.Publish( + exchange=exchange, + routing_key=routing_key or '', + mandatory=mandatory, + immediate=immediate, + ), + header.ContentHeader( + body_size=len(payload), properties=self._properties + ), + ] # Calculate how many body frames are needed - pieces = int(math.ceil(len(payload) / - float(self.channel.maximum_frame_size))) + frame_size = self.channel.maximum_frame_size or len(payload) or 1 + pieces = math.ceil(len(payload) / float(frame_size)) # Send the message for offset in range(0, pieces): - start = self.channel.maximum_frame_size * offset - end = start + self.channel.maximum_frame_size + start = frame_size * offset + end = start + frame_size if end > len(payload): end = len(payload) frames.append(body.ContentBody(payload[start:end])) @@ -286,49 +265,50 @@ def publish(self, exchange, routing_key='', mandatory=False, # If publisher confirmations are enabled, wait for the response if self.channel.publisher_confirms: response = self.channel.wait_for_confirmation() - if isinstance(response, specification.Basic.Ack): + if isinstance(response, commands.Basic.Ack): return True - elif isinstance(response, specification.Basic.Nack): + elif isinstance(response, commands.Basic.Nack): return False else: raise exceptions.UnexpectedResponseError(response) - def reject(self, requeue=False): + def reject(self, requeue: bool = False) -> None: """Reject receipt of the message to RabbitMQ. Will raise an ActionException if the message was not received from a broker. - :param bool requeue: Requeue the message + :param requeue: Requeue the message :raises: ActionException """ if not self.method: - raise exceptions.ActionException('Can not reject non-received ' - 'message') - basic_reject = specification.Basic.Reject(self.method.delivery_tag, - requeue=requeue) + raise exceptions.ActionException( + 'Can not reject non-received message' + ) + basic_reject = commands.Basic.Reject( + self.method.delivery_tag, requeue=requeue + ) self.channel.write_frame(basic_reject) - def _add_auto_message_id(self): + def _add_auto_message_id(self) -> None: """Set the message_id property to a new UUID.""" self.properties['message_id'] = str(uuid.uuid4()) - def _add_timestamp(self): + def _add_timestamp(self) -> None: """Add the timestamp to the properties""" - self.properties['timestamp'] = datetime.datetime.utcnow() + self.properties['timestamp'] = datetime.datetime.now(tz=datetime.UTC) @staticmethod - def _as_datetime(value): + def _as_datetime( + value: datetime.datetime + | time.struct_time + | int + | float + | str + | bytes, + ) -> datetime.datetime | None: """Return the passed in value as a ``datetime.datetime`` value. :param value: The value to convert or pass through - :type value: datetime.datetime - :type value: time.struct_time - :type value: int - :type value: float - :type value: str - :type value: bytes - :type value: unicode - :rtype: datetime.datetime :raises: TypeError """ @@ -339,76 +319,89 @@ def _as_datetime(value): return value if isinstance(value, time.struct_time): - return datetime.datetime(*value[:6]) + return datetime.datetime(*value[:6], tzinfo=datetime.UTC) - if utils.is_string(value): + if isinstance(value, (bytes, str)): value = int(value) if isinstance(value, float) or isinstance(value, int): - return datetime.datetime.fromtimestamp(value) + return datetime.datetime.fromtimestamp(value, tz=datetime.UTC) - raise TypeError('Could not cast a %s value to a datetime.datetime' % - type(value)) + raise TypeError( + f'Could not cast a {value} value to a datetime.datetime' + ) - def _auto_serialize(self, body_value): + def _auto_serialize( + self, value: str | bytes | memoryview | dict | list + ) -> str | bytes: """Automatically serialize the body as JSON if it is a dict or list. - :param mixed body_value: The message body passed into the constructor - :return: bytes|str + :param value: The message body passed into the constructor """ - if isinstance(body_value, dict) or isinstance(body_value, list): + if isinstance(value, dict) or isinstance(value, list): self.properties['content_type'] = 'application/json' - return json.dumps(body_value, ensure_ascii=False) - return body_value - - def _coerce_properties(self): + return json.dumps(value, ensure_ascii=False) + return value + + # Map pamqp 4 amqp_type strings to Python types for coercion + _AMQP_TYPE_MAP: typing.ClassVar[dict[str, str]] = { + 'shortstr': 'str', + 'octet': 'int', + 'table': 'dict', + 'timestamp': 'datetime', + } + + def _coerce_properties(self) -> None: """Force properties to be set to the correct data type""" for key, value in self.properties.items(): - _type = specification.Basic.Properties.type(key) if self.properties[key] is None: continue - if _type == 'shortstr': - if not utils.is_string(value): + try: + amqp_type = commands.Basic.Properties.amqp_type(key) + except AttributeError: + continue + python_type = self._AMQP_TYPE_MAP.get(amqp_type) + if python_type == 'str': + if not isinstance(value, (bytes, str)): LOGGER.warning('Coercing property %s to bytes', key) value = str(value) self.properties[key] = utils.maybe_utf8_encode(value) - elif _type == 'octet' and not isinstance(value, int): + elif python_type == 'int': LOGGER.warning('Coercing property %s to int', key) try: self.properties[key] = int(value) except TypeError as error: LOGGER.warning('Could not coerce %s: %s', key, error) - elif _type == 'table' and not isinstance(value, dict): + elif python_type == 'dict' and not isinstance(value, dict): LOGGER.warning('Resetting invalid value for %s to None', key) self.properties[key] = {} - if key == 'timestamp': + if python_type == 'datetime': self.properties[key] = self._as_datetime(value) @property - def _invalid_properties(self): + def _invalid_properties(self) -> list[str]: """Return a list of invalid properties that currently exist in the the properties that are set. - :rtype: list - """ - return [key for key in self.properties - if key not in specification.Basic.Properties.attributes()] + return [ + key + for key in self.properties + if key not in commands.Basic.Properties.attributes() + ] @property - def _properties(self): + def _properties(self) -> commands.Basic.Properties: """Return a new Basic.Properties object representing the message properties. - :rtype: pamqp.specification.Basic.Properties - """ self._prune_invalid_properties() self._coerce_properties() - return specification.Basic.Properties(**self.properties) + return commands.Basic.Properties(**self.properties) - def _prune_invalid_properties(self): + def _prune_invalid_properties(self) -> None: """Remove invalid properties from the message properties.""" for key in self._invalid_properties: LOGGER.warning('Removing invalid property "%s"', key) diff --git a/rabbitpy/py.typed b/rabbitpy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/rabbitpy/simple.py b/rabbitpy/simple.py index df99ef4..1ed0c05 100644 --- a/rabbitpy/simple.py +++ b/rabbitpy/simple.py @@ -1,15 +1,16 @@ """ Wrapper methods for easy access to common operations, making them both less -complex and less verbose for one off or simple use cases. +complex and less verbose for one-off or simple use cases. """ -from rabbitpy import amqp_queue -from rabbitpy import connection -from rabbitpy import exchange -from rabbitpy import message +import types +import typing -class SimpleChannel(object): +from rabbitpy import amqp_queue, channel, connection, exchange, message + + +class SimpleChannel: """The rabbitpy.simple.Channel class creates a context manager implementation for use on a single channel where the connection is automatically created and managed for you. @@ -27,17 +28,23 @@ class SimpleChannel(object): :class:`~rabbitpy.connection.Connection` class documentation. """ - def __init__(self, uri): + + def __init__(self, uri: str): self.connection = None self.channel = None self.uri = uri - def __enter__(self): + def __enter__(self) -> channel.Channel: self.connection = connection.Connection(self.uri) self.channel = self.connection.channel() return self.channel - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ): if not self.channel.closed: self.channel.close() if not self.connection.closed: @@ -46,140 +53,160 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise -def consume(uri=None, queue_name=None, no_ack=False, prefetch=None, - priority=None): +def consume( + uri: str | None = None, + queue_name: str | None = None, + no_ack: bool | None = False, + prefetch: int | None = None, + priority: int | None = None, +) -> typing.Generator[message.Message, None, None]: """Consume messages from the queue as a generator: .. code:: python - for message in rabbitpy.consume('amqp://localhost/%2F', 'my_queue'): + for message in rabbitpy.consume('amqp://localhost/%2F', 'my-queue'): message.ack() - :param str uri: AMQP connection URI - :param str queue_name: The name of the queue to consume from - :param bool no_ack: Do not require acknowledgements - :param int prefetch: Set a prefetch count for the channel - :param int priority: Set the consumer priority - :rtype: :py:class:`Iterator` + :param uri: AMQP connection URI + :param queue_name: The name of the queue to consume from + :param no_ack: Do not require acknowledgements + :param prefetch: Set a prefetch count for the channel + :param priority: Set the consumer priority :raises: py:class:`ValueError` """ _validate_name(queue_name, 'queue') - with SimpleChannel(uri) as channel: - queue = amqp_queue.Queue(channel, queue_name) - for msg in queue.consume(no_ack, prefetch, priority): - yield msg + with SimpleChannel(uri) as chan: + queue = amqp_queue.Queue(chan, queue_name) + yield from queue.consume(no_ack, prefetch, priority) -def get(uri=None, queue_name=None): +def get( + uri: str | None = None, queue_name: str | None = None +) -> message.Message | None: """Get a message from RabbitMQ, auto-acknowledging with RabbitMQ if one is returned. Invoke directly as ``rabbitpy.get()`` - :param str uri: AMQP URI to connect to - :param str queue_name: The queue name to get the message from - :rtype: py:class:`rabbitpy.message.Message` or None + :param uri: AMQP URI to connect to + :param queue_name: The queue name to get the message from :raises: py:class:`ValueError` """ _validate_name(queue_name, 'queue') - with SimpleChannel(uri) as channel: - queue = amqp_queue.Queue(channel, queue_name) + with SimpleChannel(uri) as chan: + queue = amqp_queue.Queue(chan, queue_name) return queue.get(False) -def publish(uri=None, exchange_name=None, routing_key=None, - body=None, properties=None, confirm=False): +def publish( + uri: str | None = None, + exchange_name: str | None = None, + routing_key: str | None = None, + body: bytes | str | None = None, + properties: dict | None = None, + confirm: bool = False, +) -> bool | None: """Publish a message to RabbitMQ. This should only be used for one-off publishing, as you will suffer a performance penalty if you use it repeatedly instead creating a connection and channel and publishing on that - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange to publish to - :param str routing_key: The routing_key to publish with + :param uri: AMQP URI to connect to + :param exchange_name: The exchange to publish to + :param routing_key: The routing_key to publish with :param body: The message body - :type body: str or unicode or bytes or dict or list - :param dict properties: Dict representation of Basic.Properties - :param bool confirm: Confirm this delivery with Publisher Confirms - :rtype: bool or None + :param properties: Dict representation of Basic.Properties + :param confirm: Confirm this delivery with Publisher Confirms """ if exchange_name is None: exchange_name = '' - with SimpleChannel(uri) as channel: - msg = message.Message(channel, body or '', properties or dict()) + with SimpleChannel(uri) as chan: + msg = message.Message(chan, body or '', properties or {}) if confirm: - channel.enable_publisher_confirms() - return msg.publish(exchange_name, routing_key or '', - mandatory=True) + chan.enable_publisher_confirms() + return msg.publish( + exchange_name, routing_key or '', mandatory=True + ) else: msg.publish(exchange_name, routing_key or '') -def create_queue(uri=None, queue_name='', durable=True, auto_delete=False, - max_length=None, message_ttl=None, expires=None, - dead_letter_exchange=None, dead_letter_routing_key=None, - arguments=None): +def create_queue( + uri: str | None = None, + queue_name: str = '', + durable: bool = True, + auto_delete: bool = False, + max_length: int | None = None, + message_ttl: int | None = None, + expires: int | None = None, + dead_letter_exchange: str | None = None, + dead_letter_routing_key: str | None = None, + arguments: dict | None = None, +): """Create a queue with RabbitMQ. This should only be used for one-off operations. If a queue name is omitted, the name will be automatically generated by RabbitMQ. - :param str uri: AMQP URI to connect to - :param str queue_name: The queue name to create + :param uri: AMQP URI to connect to + :param queue_name: The queue name to create :param durable: Indicates if the queue should survive a RabbitMQ is restart - :type durable: bool - :param bool auto_delete: Automatically delete when all consumers disconnect - :param int max_length: Maximum queue length - :param int message_ttl: Time-to-live of a message in milliseconds + :param auto_delete: Automatically delete when all consumers disconnect + :param max_length: Maximum queue length + :param message_ttl: Time-to-live of a message in milliseconds :param expires: Milliseconds until a queue is removed after becoming idle - :type expires: int :param dead_letter_exchange: Dead letter exchange for rejected messages - :type dead_letter_exchange: str :param dead_letter_routing_key: Routing key for dead lettered messages - :type dead_letter_routing_key: str - :param dict arguments: Custom arguments for the queue + :param arguments: Custom arguments for the queue :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` """ - _validate_name(queue_name, 'queue') - with SimpleChannel(uri) as channel: - amqp_queue.Queue(channel, queue_name, - durable=durable, - auto_delete=auto_delete, - max_length=max_length, - message_ttl=message_ttl, - expires=expires, - dead_letter_exchange=dead_letter_exchange, - dead_letter_routing_key=dead_letter_routing_key, - arguments=arguments).declare() - - -def delete_queue(uri=None, queue_name=None): + if queue_name: + _validate_name(queue_name, 'queue') + with SimpleChannel(uri) as c: + amqp_queue.Queue( + c, + queue_name, + durable=durable, + auto_delete=auto_delete, + max_length=max_length, + message_ttl=message_ttl, + expires=expires, + dead_letter_exchange=dead_letter_exchange, + dead_letter_routing_key=dead_letter_routing_key, + arguments=arguments, + ).declare() + + +def delete_queue(uri: str | None = None, queue_name: str = '') -> None: """Delete a queue from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str queue_name: The queue name to delete - :rtype: bool + :param uri: AMQP URI to connect to + :param queue_name: The queue name to delete :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` """ _validate_name(queue_name, 'queue') - with SimpleChannel(uri) as channel: - amqp_queue.Queue(channel, queue_name).delete() + with SimpleChannel(uri) as c: + amqp_queue.Queue(c, queue_name).delete() -def create_direct_exchange(uri=None, exchange_name=None, durable=True): +def create_direct_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create a direct exchange with RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` @@ -187,13 +214,17 @@ def create_direct_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.DirectExchange, durable) -def create_fanout_exchange(uri=None, exchange_name=None, durable=True): +def create_fanout_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create a fanout exchange with RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` @@ -201,13 +232,17 @@ def create_fanout_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.FanoutExchange, durable) -def create_headers_exchange(uri=None, exchange_name=None, durable=True): +def create_headers_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create a headers exchange with RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` @@ -215,13 +250,17 @@ def create_headers_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.HeadersExchange, durable) -def create_topic_exchange(uri=None, exchange_name=None, durable=True): +def create_topic_exchange( + uri: str | None = None, + exchange_name: str | None = None, + durable: bool = True, +) -> None: """Create an exchange from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param durable: Exchange should survive server restarts :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` @@ -229,44 +268,52 @@ def create_topic_exchange(uri=None, exchange_name=None, durable=True): _create_exchange(uri, exchange_name, exchange.TopicExchange, durable) -def delete_exchange(uri=None, exchange_name=None): +def delete_exchange( + uri: str | None = None, exchange_name: str | None = None +) -> None: """Delete an exchange from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to delete + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to delete :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` """ _validate_name(exchange_name, 'exchange') - with SimpleChannel(uri) as channel: - exchange.Exchange(channel, exchange_name).delete() + with SimpleChannel(uri) as c: + exchange.Exchange(c, exchange_name).delete() -def _create_exchange(uri, exchange_name, exchange_class, durable): +def _create_exchange( + uri: str | None, + exchange_name: str | None, + exchange_class: typing.Callable, + durable: bool, +) -> None: """Create an exchange from RabbitMQ. This should only be used for one-off operations. - :param str uri: AMQP URI to connect to - :param str exchange_name: The exchange name to create - :param bool durable: Exchange should survive server restarts + :param uri: AMQP URI to connect to + :param exchange_name: The exchange name to create + :param exchange_class: The exchange class to use + :param durable: Exchange should survive server restarts :raises: :py:class:`ValueError` :raises: :py:class:`rabbitpy.RemoteClosedException` """ _validate_name(exchange_name, 'exchange') - with SimpleChannel(uri) as channel: - exchange_class(channel, exchange_name, durable=durable).declare() + with SimpleChannel(uri) as c: + exchange_class(c, exchange_name, durable=durable).declare() -def _validate_name(value, obj_type): +def _validate_name(value: str, obj_type: str) -> None: """Validate the specified name is set. - :param str value: The value to validate - :param str obj_type: The object type for the error message if needed + :param value: The value to validate + :param obj_type: The object type for the error message if needed :raises: ValueError """ if not value: - raise ValueError('You must specify the {} name'.format(obj_type)) + raise ValueError(f'You must specify the {obj_type} name') diff --git a/rabbitpy/state.py b/rabbitpy/state.py new file mode 100644 index 0000000..96434c2 --- /dev/null +++ b/rabbitpy/state.py @@ -0,0 +1,88 @@ +"""Common base class for objects that need to maintain state.""" + +import typing + + +class StatefulBase: + """Base class for classes that need to maintain state such as + connection and channel. + + """ + + CLOSED = 0 + CLOSING = 1 + OPEN = 2 + OPENING = 3 + + STATES: typing.ClassVar[dict[int, str]] = { + 0: 'Closed', + 1: 'Closing', + 2: 'Open', + 3: 'Opening', + } + + def __init__(self, *args: object, **kwargs: object) -> None: + """Create a new instance of the object defaulting to a closed state.""" + self._state: int = self.CLOSED + super().__init__(*args, **kwargs) + + def _set_state(self, value: int) -> None: + """Set the state to the specified value, validating it is a supported + state value. + + :param value: The new state value + + """ + if value not in self.STATES.keys(): + raise ValueError(f'Invalid state value: {value}') + self._state = value + + @property + def is_closed(self) -> bool: + """Returns True if in the CLOSED runtime state""" + return self._state == self.CLOSED + + @property + def closed(self) -> bool: + """Alias for is_closed for backward compatibility.""" + return self._state == self.CLOSED + + @property + def is_closing(self) -> bool: + """Returns True if in the CLOSING runtime state""" + return self._state == self.CLOSING + + @property + def closing(self) -> bool: + """Alias for is_closing for backward compatibility.""" + return self._state == self.CLOSING + + @property + def is_open(self) -> bool: + """Returns True if in the OPEN runtime state""" + return self._state == self.OPEN + + @property + def open(self) -> bool: + """Alias for is_open for backward compatibility.""" + return self._state == self.OPEN + + @property + def is_opening(self) -> bool: + """Returns True if in the OPENING runtime state""" + return self._state == self.OPENING + + @property + def opening(self) -> bool: + """Alias for is_opening for backward compatibility.""" + return self._state == self.OPENING + + @property + def state(self) -> int: + """Return the runtime state value""" + return self._state + + @property + def state_description(self) -> str: + """Returns the text based description of the runtime state""" + return self.STATES[self._state] diff --git a/rabbitpy/tx.py b/rabbitpy/tx.py index 5b8d415..f3cd55a 100644 --- a/rabbitpy/tx.py +++ b/rabbitpy/tx.py @@ -3,11 +3,19 @@ and allows for any AMQP command to be issued, then committed or rolled back. """ + +from __future__ import annotations + import logging -from pamqp import specification as spec +import types +import typing + +from pamqp import commands -from rabbitpy import base -from rabbitpy import exceptions +from rabbitpy import base, exceptions + +if typing.TYPE_CHECKING: + from rabbitpy import channel as chan LOGGER = logging.getLogger(__name__) @@ -26,24 +34,27 @@ class Tx(base.AMQPClass): mandatory flags on Basic.Publish methods is not defined. :param channel: The channel object to start the transaction on - :type channel: :py:class:`rabbitpy.channel.Channel` """ - def __init__(self, channel): - super(Tx, self).__init__(channel, 'Tx') + + def __init__(self, channel: chan.Channel): + super().__init__(channel, 'Tx') self._selected = False - def __enter__(self): + def __enter__(self) -> Tx: """For use as a context manager, return a handle to this object instance. - :rtype: Connection - """ self.select() return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + unused_exc_tb: types.TracebackType | None, + ) -> bool | None: """When leaving the context, examine why the context is leaving, if it's an exception or what. @@ -51,29 +62,29 @@ def __exit__(self, exc_type, exc_val, exc_tb): if exc_type: LOGGER.warning('Exiting Transaction on exception: %r', exc_val) if self._selected: - self.rollback() - raise exc_val - else: - LOGGER.debug('Committing transaction on exit of context block') - if self._selected: - self.commit() - - def select(self): + try: + self.rollback() + except exceptions.NoActiveTransactionError: + LOGGER.warning('Skipping rollback on a closed channel') + return False + LOGGER.debug('Committing transaction on exit of context block') + if self._selected: + self.commit() + return None + + def select(self) -> bool: """Select standard transaction mode This method sets the channel to use standard transactions. The client must use this method at least once on a channel before using the Commit or Rollback methods. - :rtype: bool - """ - response = self._rpc(spec.Tx.Select()) - result = isinstance(response, spec.Tx.SelectOk) - self._selected = result - return result + response = self._rpc(commands.Tx.Select()) + self._selected = isinstance(response, commands.Tx.SelectOk) + return self._selected - def commit(self): + def commit(self) -> bool: """Commit the current transaction This method commits all message publications and acknowledgments @@ -81,18 +92,17 @@ def commit(self): immediately after a commit. :raises: rabbitpy.exceptions.NoActiveTransactionError - :rtype: bool """ try: - response = self._rpc(spec.Tx.Commit()) + response = self._rpc(commands.Tx.Commit()) except exceptions.ChannelClosedException as error: LOGGER.warning('Error committing transaction: %s', error) - raise exceptions.NoActiveTransactionError() - self._selected = False - return isinstance(response, spec.Tx.CommitOk) + raise exceptions.NoActiveTransactionError() from error + self._selected = isinstance(response, commands.Tx.CommitOk) + return self._selected - def rollback(self): + def rollback(self) -> bool: """Abandon the current transaction This method abandons all message publications and acknowledgments @@ -102,13 +112,12 @@ def rollback(self): recover call should be issued. :raises: rabbitpy.exceptions.NoActiveTransactionError - :rtype: bool """ try: - response = self._rpc(spec.Tx.Rollback()) + response = self._rpc(commands.Tx.Rollback()) except exceptions.ChannelClosedException as error: LOGGER.warning('Error rolling back transaction: %s', error) - raise exceptions.NoActiveTransactionError() - self._selected = False - return isinstance(response, spec.Tx.RollbackOk) + raise exceptions.NoActiveTransactionError() from error + self._selected = isinstance(response, commands.Tx.RollbackOk) + return self._selected diff --git a/rabbitpy/url_parser.py b/rabbitpy/url_parser.py new file mode 100644 index 0000000..b82e04a --- /dev/null +++ b/rabbitpy/url_parser.py @@ -0,0 +1,262 @@ +"""Parse URLS""" + +import logging +import ssl +import typing +import urllib.parse + +from pamqp import constants + +LOGGER = logging.getLogger(__name__) + +AMQP = 'amqp' +AMQPS = 'amqps' +DEFAULT_CHANNEL_MAX = 65535 +DEFAULT_TIMEOUT = 3 +DEFAULT_HEARTBEAT_INTERVAL = 60 +DEFAULT_LOCALE = 'en_US' +DEFAULT_URL = 'amqp://guest:guest@localhost:5672/%2F' +DEFAULT_VHOST = '%2F' +GUEST = 'guest' +PORTS = {'amqp': 5672, 'amqps': 5671} +SSL_CERT_MAP: dict[str, ssl.VerifyMode] = { + 'ignore': ssl.CERT_NONE, + 'optional': ssl.CERT_OPTIONAL, + 'required': ssl.CERT_REQUIRED, +} + + +class SslOptions(typing.TypedDict): + """SSL connection options parsed from an AMQP URL.""" + + check_hostname: bool + cafile: str | None + capath: str | None + certfile: str | None + keyfile: str | None + verify: ssl.VerifyMode | None + + +class ConnectionArgs(typing.TypedDict): + """Parsed AMQP connection arguments.""" + + host: str + port: int + virtual_host: str + username: str + password: str + timeout: int + heartbeat: int + frame_max: int + channel_max: int + locale: str | None + ssl: bool + ssl_options: SslOptions + + +def parse(url: str | None = DEFAULT_URL) -> ConnectionArgs: + """Parse the AMQP URL passed in and return the configuration + information in a dictionary of values. + + The URL format is as follows: + + amqp[s]://username:password@host:port/virtual_host[?query string] + + Values in the URL such as the virtual_host should be URL encoded or + quoted just as a URL would be in a web browser. The default virtual + host / in RabbitMQ should be passed as %2F. + + Default values: + + - If port is omitted, port 5762 is used for AMQP and port 5671 is + used for AMQPS + - If username or password is omitted, the default value is guest + - If the virtual host is omitted, the default value of %2F is used + + Query string options: + + - heartbeat + - channel_max + - frame_max + - locale + - cacertfile - Path to CA certificate file + - capath - Path to directory containing CA certificates + - certfile - Path to client certificate file + - keyfile - Path to client certificate key + - verify - Server certificate validation requirements (1) + - ssl_check_hostname - Whether to validate the server hostname (2) + + (1) Should be one of three values: + + - ignore - Ignore the cert if provided (default) + - optional - Cert is validated if provided + - required - Cert is required and validated + + (2) Should be one of the following values: + + - 0, false, no - Do not validate the server hostname + - 1, true, yes - Validate the server hostname + + + :param url: The AMQP url passed in + :raises: ValueError + + """ + parsed = urllib.parse.urlparse(url) + + _validate_uri_scheme(parsed.scheme) + + # Toggle the SSL flag based upon the URL scheme and if SSL is enabled + use_ssl = bool(parsed.scheme == AMQPS and ssl) + + # Ensure that SSL is available if SSL is requested + if parsed.scheme == 'amqps' and not ssl: + LOGGER.warning('SSL requested but not available, disabling') + + # Figure out the port as specified by the scheme + scheme_port = PORTS[AMQPS] if parsed.scheme == AMQPS else PORTS[AMQP] + + # Set the vhost to be after the base slash if it was specified + # parsed.path is str when the input URL is str (always our case). + path = ( + parsed.path if isinstance(parsed.path, str) else parsed.path.decode() + ) + vhost: str = DEFAULT_VHOST + if path: + vhost = path[1:] or DEFAULT_VHOST + + # Parse the query string + raw_query = parsed.query + query_args = urllib.parse.parse_qs( + raw_query.decode('utf-8') + if isinstance(raw_query, bytes) + else raw_query + ) + + check_hostname_raw = _query_args_str('ssl_check_hostname', query_args, '') + check_hostname = (check_hostname_raw or '').lower() not in ( + '0', + 'false', + 'no', + ) + + # Return the configuration dictionary to use when connecting + hostname = parsed.hostname + host: str = ( + hostname.decode() + if isinstance(hostname, bytes) + else hostname or 'localhost' + ) + return ConnectionArgs( + host=host, + port=parsed.port or scheme_port, + virtual_host=urllib.parse.unquote(vhost), + username=urllib.parse.unquote(parsed.username or GUEST), + password=urllib.parse.unquote(parsed.password or GUEST), + timeout=_query_args_int('timeout', query_args, DEFAULT_TIMEOUT), + heartbeat=_query_args_int( + 'heartbeat', query_args, DEFAULT_HEARTBEAT_INTERVAL + ), + frame_max=_query_args_int( + 'frame_max', query_args, constants.FRAME_MAX_SIZE + ), + channel_max=_query_args_int( + 'channel_max', query_args, DEFAULT_CHANNEL_MAX + ), + locale=_query_args_str('locale', query_args), + ssl=use_ssl, + ssl_options=SslOptions( + check_hostname=check_hostname, + cafile=_query_args_multi_key_value( + ['cacertfile', 'ssl_cacert', 'cafile'], query_args + ), + capath=_query_args_str('capath', query_args), + certfile=_query_args_multi_key_value( + ['certfile', 'ssl_cert'], query_args + ), + keyfile=_query_args_multi_key_value( + ['keyfile', 'ssl_key'], query_args + ), + verify=_query_args_ssl_validation(query_args), + ), + ) + + +def _query_args_int( + key: str, values: dict[str, list[str]], default: int +) -> int: + """Return the query arg value as an integer for the specified key or + return the specified default value. + + :param key: The key to return the value for + :param values: The query value dict returned by urlparse + :param default: The default return value + + """ + return int(values.get(key, [str(default)])[0]) + + +def _query_args_str( + key: str, + values: dict[str, list[str]], + default: str | None = None, +) -> str | None: + """Return the value from the query arguments for the specified key + or the default value. + + :param key: The key to get the value for + :param values: The query value dict returned by urlparse + + """ + result = values.get(key, [default])[0] + return result + + +def _query_args_multi_key_value( + keys: list[str], values: dict[str, list[str]] +) -> str | None: + """Try and find the query string value where the value can be specified + with different keys. + + :param keys: The keys to check + :param values: The query value dict returned by urlparse + + """ + for key in keys: + value = _query_args_str(key, values) + if value is not None: + return value + return None + + +def _query_args_ssl_validation( + values: dict[str, list[str]], +) -> ssl.VerifyMode | None: + """Return the value mapped from the string value in the query string + for the AMQP URL specifying which level of server certificate + validation is required, if any. + + :param values: The dict of query values from the AMQP URI + + """ + validation = _query_args_multi_key_value( + ['verify', 'ssl_validation'], values + ) + if not validation: + return None + elif validation not in SSL_CERT_MAP: + raise ValueError( + f'Unsupported server cert validation option: {validation}' + ) + return SSL_CERT_MAP[validation] + + +def _validate_uri_scheme(scheme: bytes | str) -> None: + """Ensure that the specified URI scheme is supported by rabbitpy + + :param scheme: The value to validate + :raises: ValueError + + """ + if scheme not in list(PORTS.keys()): + raise ValueError(f'Unsupported URI scheme: {scheme!r}') diff --git a/rabbitpy/utils.py b/rabbitpy/utils.py index 5187348..0873117 100644 --- a/rabbitpy/utils.py +++ b/rabbitpy/utils.py @@ -1,103 +1,108 @@ -"""Utilities to make Python 3 support easier, providing wrapper methods which -can call the appropriate method for either Python 2 or Python 3 but creating -a single API point for rabbitpy to use. +""" +Utilities +========= """ + import collections -# pylint: disable=unused-import,import-error -try: - import Queue as queue -except ImportError: - import queue +import logging import platform import socket -# pylint: disable=import-error -try: - from urllib import parse as _urlparse -except ImportError: - import urlparse as _urlparse - -from pamqp import PYTHON3 +from urllib import parse +LOGGER = logging.getLogger('rabbitpy') PYPY = platform.python_implementation() == 'PyPy' +PYTHON3 = True # Always True — rabbitpy 3.x requires Python 3 -Parsed = collections.namedtuple('Parsed', - 'scheme,netloc,path,params,query,fragment,' - 'username,password,hostname,port') +Parsed = collections.namedtuple( + 'Parsed', + 'scheme,netloc,path,params,query,fragment,username,password,hostname,port', +) -def maybe_utf8_encode(value): +def maybe_utf8_encode(value: str | bytes) -> bytes: """Cross-python version method that will attempt to utf-8 encode a string. - :param mixed value: The value to maybe encode - :return: str + :param value: The value to maybe encode """ - - if PYTHON3: - if is_string(value) and not isinstance(value, bytes): - return bytes(value, 'utf-8') - return value - if isinstance(value, unicode): # pylint: disable=undefined-variable + try: return value.encode('utf-8') - return value - - -def parse_qs(query_string): - """Cross-python version method for parsing a query string. - - :param str query_string: The query string to parse - :return: tuple - """ - return _urlparse.parse_qs(query_string) + except AttributeError: + return value -def urlparse(url): +def urlparse(url: str) -> Parsed: """Parse a URL, returning a named tuple result. - :param str url: The URL to parse - :rtype: collections.namedtuple + :param url: The URL to parse """ - value = 'http%s' % url[4:] if url[:4] == 'amqp' else url - parsed = _urlparse.urlparse(value) - return Parsed(parsed.scheme.replace('http', 'amqp'), parsed.netloc, - parsed.path, parsed.params, parsed.query, parsed.fragment, - parsed.username, - parsed.password, - parsed.hostname, parsed.port) + value = f'http{url[4:]}' if url[:4] == 'amqp' else url + parsed = parse.urlparse(value) + return Parsed( + parsed.scheme.replace('http', 'amqp'), + parsed.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + parsed.username, + parsed.password, + parsed.hostname, + parsed.port, + ) + + +def is_string(value: object) -> bool: + """Return True if the value is a string or bytes value. + + :param value: The value to check + + """ + return isinstance(value, (str, bytes)) -def unquote(value): - """Cross-python version method for unquoting a URI value. +def parse_qs(query_string: str) -> dict[str, list[str]]: + """Wrapper for :func:`urllib.parse.parse_qs`. - :param str value: The value to unquote - :rtype: str + :param query_string: The query string to parse """ - return _urlparse.unquote(value) + return parse.parse_qs(query_string) -def is_string(value): - """Check to see if the value is a string in Python 2 and 3. +def unquote(value: str) -> str: + """Wrapper for :func:`urllib.parse.unquote`. - :param bytes|str|unicode value: The value to check - :rtype: bool + :param value: The value to unquote """ - checks = [isinstance(value, bytes), isinstance(value, str)] - if not PYTHON3: - # pylint: disable=undefined-variable - checks.append(isinstance(value, unicode)) - return any(checks) - + return parse.unquote(value) -def trigger_write(sock): - """Notifies the IO loop we need to write a frame by writing a byte - to a local socket. - """ +def trigger_write(sock: socket.socket) -> None: try: sock.send(b'0') - except socket.error: + except OSError: pass + + +class DebuggingOptimizationMixin: + """Micro-optimization to avoid logging overhead""" + + def __init__(self): + self._debugging: bool | None = None + + @property + def _is_debugging(self) -> bool: + """Indicates that something has set the logger to ``logging.DEBUG`` + to perform a minor micro-optimization preventing ``LOGGER.debug`` calls + when they are not required. + + :return: bool + + """ + if self._debugging is None: + self._debugging = LOGGER.getEffectiveLevel() == logging.DEBUG + return self._debugging diff --git a/tests/__init__.py b/tests/__init__.py index 1b6cbb4..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,17 +0,0 @@ -# coding=utf-8 -import os -import warnings - -warnings.simplefilter('ignore', UserWarning) - - -def setup_module(): - try: - with open('build/test-environment') as f: - for line in f: - if line.startswith('export '): - line = line[7:] - name, _, value = line.strip().partition('=') - os.environ[name] = value - except IOError: - pass \ No newline at end of file diff --git a/tests/base_tests.py b/tests/base_tests.py index 89b8d2f..516b7ea 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -2,13 +2,12 @@ Test the rabbitpy.base classes """ -from rabbitpy import base, utils +from rabbitpy import base, utils from tests import helpers class AMQPClassTests(helpers.TestCase): - def test_channel_valid(self): obj = base.AMQPClass(self.channel, 'Foo') self.assertEqual(obj.channel, self.channel) @@ -25,9 +24,9 @@ def test_name_str(self): self.assertIsInstance(obj.name, str) @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') - def test_name_unicode(self): - obj = base.AMQPClass(self.channel, unicode('Foo')) - self.assertIsInstance(obj.name, unicode) + def test_name_py2_unicode(self): + obj = base.AMQPClass(self.channel, 'Foo') + self.assertIsInstance(obj.name, str) def test_name_value(self): obj = base.AMQPClass(self.channel, 'Foo') diff --git a/tests/channel_test.py b/tests/channel_test.py index e8e7004..d7bc348 100644 --- a/tests/channel_test.py +++ b/tests/channel_test.py @@ -2,12 +2,12 @@ Test the rabbitpy.channel classes """ -from rabbitpy import exceptions +from rabbitpy import exceptions from tests import helpers -class ServerCapabilitiesTest(helpers.TestCase): +class ServerCapabilitiesTest(helpers.TestCase): def test_basic_nack_disabled(self): self.channel._server_capabilities['basic.nack'] = False self.assertFalse(self.channel._supports_basic_nack) @@ -50,15 +50,23 @@ def test_publisher_confirms_enabled(self): def test_invoking_consume_raises(self): self.channel._server_capabilities['consumer_priorities'] = False - self.assertRaises(exceptions.NotSupportedError, - self.channel._consume, self, True, 100) + self.assertRaises( + exceptions.NotSupportedError, + self.channel._consume, + self, + True, + 100, + ) def test_invoking_basic_nack_raises(self): self.channel._server_capabilities['basic_nack'] = False - self.assertRaises(exceptions.NotSupportedError, - self.channel._multi_nack, 100) + self.assertRaises( + exceptions.NotSupportedError, self.channel._multi_nack, 100 + ) def test_invoking_enable_publisher_confirms_raises(self): self.channel._server_capabilities['publisher_confirms'] = False - self.assertRaises(exceptions.NotSupportedError, - self.channel.enable_publisher_confirms) + self.assertRaises( + exceptions.NotSupportedError, + self.channel.enable_publisher_confirms, + ) diff --git a/tests/data/ca.crt b/tests/data/ca.crt new file mode 100644 index 0000000..9bdc992 --- /dev/null +++ b/tests/data/ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDvTCCAqWgAwIBAgIUNgAdaPFJWObfAxCYcnph78I9MTkwDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n +dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE +AwwHVGVzdCBDQTAeFw0yNTAxMDcxODU2MThaFw0zNTAxMDUxODU2MThaMGYxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJEQzETMBEGA1UEBwwKV2FzaGluZ3RvbjERMA8G +A1UECgwIcmFiYml0cHkxEDAOBgNVBAsMB1Rlc3RpbmcxEDAOBgNVBAMMB1Rlc3Qg +Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAqbXsgubfXQFmGcHa +3+ci6zxdLCx83ZSYWGCb4B0Hu0kyYZwSFm3HNzeFutS2ZC6qsMGWs6hWLMwCl/QG +vFpKi2NMtxyWsVzczDVlXS2hWBpz6Mwpm8VPPTc99VxtKJVcMCo2djIizQ+D6B7o +NIVvJcj6TwftX/z26/oksKJxhWIMsZ2S6pk9A42THNM5rEs2NumFCKShexQ0ERbK +mkGcCVNwCVFgSzp48zUOZZEsK1164MyXN33pkMgj2/1Yq+RNze9Vkie7IiRlNwY6 +PKbf8rr9wX5MYdOYlJL0RIRAH5BHc+1T1GiRbydA3H+ugquwTw594f+ONGanyKo+ +rJdTAgMBAAGjYzBhMB0GA1UdDgQWBBR4SrV3rq0RQA23SkJW77OmT7zTTjAfBgNV +HSMEGDAWgBR4SrV3rq0RQA23SkJW77OmT7zTTjAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAem4y9lJAv48ZuBMkQSr3 +KJ/xxwccnFZYROuRMf1zxRHwHPw8eyMei34WR5WvYj1B91ynE1bcUbpPbuYPM11H +cMsBPVKortL6RATMYOnsZbJvyRywFlEuqJytXuAV6qktAHbaTz1Hal1mjflU9IrK +2+gJ3ptr1YUiscQM/klo/tRKOI9R5ZxxnYD3w5KDi6KLaQC66XelacMfdQ+1SkYq +ZNkYxByjRPkkV/+W8HyAPq1F4O/MVJgNp323/mjShGTvYpobFGHA0pw7JbbiMbSm +60eKx8Tecg1Fw2iRiWz1M9Yl7mFiRQ56gowBY2UtVyd5Bxijn/8eql3pcX53dIhU ++g== +-----END CERTIFICATE----- diff --git a/tests/data/client.crt b/tests/data/client.crt new file mode 100644 index 0000000..9a46892 --- /dev/null +++ b/tests/data/client.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID3DCCAsSgAwIBAgIUY8MyAULCRmoy5f/fuOABgZHzS+gwDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n +dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE +AwwHVGVzdCBDQTAeFw0yNTAxMDcyMTE5NTJaFw0zNTAxMDUyMTE5NTJaMHMxCzAJ +BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE +CgwMT3JnYW5pemF0aW9uMREwDwYDVQQLDAhPcmcgVW5pdDEbMBkGA1UEAwwSY2xp +ZW50LmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +pEL0OhepScBTVq2hjOU+/pISOOow8vx884YxJiHn6yh5Fretii8vmGV+ByrQY3Qp +hx9Oh0GPuFZYjzOHZmG0VvNdDRUgl31mvxCTIZqXrGX/oMuDA2mBQEsTo/3UWl40 +aaoYnBnjgAW0+NcyGejrMlKoOkr/5hSPgf8Dnnl87UaGjwGt8IUKPcAaRzPf0Ivk +BDByq5Eso8jEtAHZDDBJLu3xju5gegeVMwe5BVl1bly6fBmz0FNuiPk9smCNpTMZ +HtVGz9ft048cxAzegtDZUxcs7HVKvAB5Ysinp5tEvIq0qXd61jQU5/VORZYMowAp +7lM0KE7CQBuN6Ea2igit/wIDAQABo3UwczAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB +/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUe1HIoD6aZ0EI +0tCWzvrSieUUF2UwHwYDVR0jBBgwFoAUeEq1d66tEUANt0pCVu+zpk+8004wDQYJ +KoZIhvcNAQELBQADggEBABYY0HhAxQDdv5cjxxpjZJK7e2G9w2xX9y7Afe1uas5G +dkJ4WlWZAP5m7rS6adYGeBzQQxCJPL2bnrwvurAhLjJNCM+SlSdElcjRm362DDya +ZFPDoa0L2VQT7UxVjmnHAQPOlYxMZt8b1fw+LYMttyP48/G1PJVkqcNIlZXbAC6K +fOQhcYs3OratCgcYUPFBIq1KuDgOQsnMpJoEeEBzysl65z8LIKcOPIaEXB7S1CAw +qYU0u2Bi8jooCdaaHkBvKF/ujQYW2UyzUvbeVjY2uxF6bVnHloyNS34V7Y7kL4ea +V+2NUW74kgKjTaHuFx6CP9i/i6dIBh28xe4Zg//hnX8= +-----END CERTIFICATE----- diff --git a/tests/data/client.key b/tests/data/client.key new file mode 100644 index 0000000..24258e4 --- /dev/null +++ b/tests/data/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkQvQ6F6lJwFNW +raGM5T7+khI46jDy/HzzhjEmIefrKHkWt62KLy+YZX4HKtBjdCmHH06HQY+4VliP +M4dmYbRW810NFSCXfWa/EJMhmpesZf+gy4MDaYFASxOj/dRaXjRpqhicGeOABbT4 +1zIZ6OsyUqg6Sv/mFI+B/wOeeXztRoaPAa3whQo9wBpHM9/Qi+QEMHKrkSyjyMS0 +AdkMMEku7fGO7mB6B5UzB7kFWXVuXLp8GbPQU26I+T2yYI2lMxke1UbP1+3TjxzE +DN6C0NlTFyzsdUq8AHliyKenm0S8irSpd3rWNBTn9U5FlgyjACnuUzQoTsJAG43o +RraKCK3/AgMBAAECggEALvBBCP6s7fnK9shsEy6JJ0bHdUMa1Lyo2g/9KHfk5XJt +07uOGCAuuh46esLVxK2zljywfH8UGil2aglgBjYiSfyGGRBx/Ugk3bFXUfFyqR2G +hETTfdyexigOBz0n+uDTmmQ0XxsYwwMeeUNJOlUwLHOpResbn/w9G/kq6BSwSb75 +wCdGyGVvN0hy3H2c0sUIIa8ppZD2rCJ1bwBbiW5bVExRZfe/HckILMz69xrGfH70 +gc6eHQqHF20J9nihwO+DugY50eb/RY3/FA23umnIHx6Iw2jZit9LTD0s3DOXyOAD +w1DEE3BxiZyiCdyJ83HC5L4dtfZVlxeaIiuTkqX2qQKBgQDToGFw3rUBqR+GL+lW +kuJh43/ERZFAWYhPt/ZDS5sVRX9uO2lG3XsOQNHSOYIfFYubaso5Sjr4HuUWpT71 +uhg+Vj0VdAmNRTjHu0LZPq4LJEm+HLEDShdLTWshgYVvH3y9ZkoRTMUKMPg9W+il +k8G6chZE/dcGasOoKRtsiKSgBwKBgQDGtCH8WQyrwpUpiwT+bSHdZ4eQouPZVCrZ +3GVVRmd3lO4hh2+DrnDKshVs6myFTjy//3MB4R7S8glAoJJb3cXzHXv1yL4n2K0q +1420sedgMM2Ot6+Y8N7rJi3x9PM3TEFnOEn/RbhvW4PhvRXqyAdLQqy0monWrMaZ +RkclChWUSQKBgQDEH54uPDcDkvjkIwLceMPUdEG0Y8R5HoB1YMeZFjhjPkUao3St +eBTS4L+TVDiDFjSLlCxVa7W6vOcSVZJDqHNPUhzKBP/VLJGyiJkrPCuPp9Gvnmdt +5PwxjU37f01p7oRsAqAh6EOzbi6grsyspoKjh5eb2KOuDsPE1FPWAcyPgQKBgQCw +bZSXiUHU6AqlPkiK179v5NLbu6Xve+ooUqau1rpb1SKzJpv/Ic4IS6L2eBcvLc6G +83vcOVSzHDDW7zvE0d9HA/DixQECRENNfOLXg/ba07pQdAw4Efb8d4PoCAREHzMb +QUGCpRcj2O/6aXeiZUUEsAjEu0qUEAiuY1YcLx4F8QKBgQDOHVjpzGS3mM9URwj/ +H8Fo5SDkDr2ozhkOXwcZ8bjVFcmFVWt3Si1DgNif5yL1TcCs7OARNR59upZI+ZIE +D4hUSGpiQZPfJhZofJ9o3RZGo6ri8lVJNvanK9eLGHyyFWkhXkv5x1xSL1+clE+V +c8AJl4utE0z5ry1MggVx/6NaXg== +-----END PRIVATE KEY----- diff --git a/tests/data/server.crt b/tests/data/server.crt new file mode 100644 index 0000000..4da4215 --- /dev/null +++ b/tests/data/server.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIUY8MyAULCRmoy5f/fuOABgZHzS+kwDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkRDMRMwEQYDVQQHDApXYXNoaW5n +dG9uMREwDwYDVQQKDAhyYWJiaXRweTEQMA4GA1UECwwHVGVzdGluZzEQMA4GA1UE +AwwHVGVzdCBDQTAeFw0yNTAxMDcyMTIwMTZaFw0zNTAxMDUyMTIwMTZaMHMxCzAJ +BgNVBAYTAlVTMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE +CgwMT3JnYW5pemF0aW9uMREwDwYDVQQLDAhPcmcgVW5pdDEbMBkGA1UEAwwSc2Vy +dmVyLmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +x6jKJIJVyeng0eOLtR4RDeiZwSzO2AZewyFpj+kPPv7bdycRW23zNQfXi3UI8BHk +aW0aWDvCmEHf5D4+ulze8LsCK6mcr+EUnEYclHSIp+cwW7TsFr5qFhKrqjTwWQkw +A5Yu7QsIcl5+Xa8GDnuSu8Hg5YHlRHgn71qgmEGplQBPx5megPjWEa6a6d1XqusQ +ZizbB5L/6bIS3lV4UmNxkYEQmZGhQPe5Vu/Zx6y0Q6Pddg25GGlx0C94sUaMQiuH +JqOrGKE2YkJ01+sLXEoAfWiANAxqElNJnuAMXeXvQKgC1bl1/DSTF78Aj03YuYxJ +LgiCB/QZBsygAqoElx/pwQIDAQABo4GgMIGdMAwGA1UdEwEB/wQCMAAwDgYDVR0P +AQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMCgGA1UdEQQhMB+CEnNlcnZl +ci5leGFtcGxlLmNvbYIJbG9jYWxob3N0MB0GA1UdDgQWBBR7cvSXwxzK4o4iHWbg +nAY+257gezAfBgNVHSMEGDAWgBR4SrV3rq0RQA23SkJW77OmT7zTTjANBgkqhkiG +9w0BAQsFAAOCAQEASkZ0aljwhUzQo/15v6dzKPUXuFpU1JTPA3Pg04BpcThJuVt5 +Gml5wU207W5+cI+PJW/cF+/MseHQnp/eQOQJXb5GwnkbElbbecaAeCTMpLXsG+Q1 +x2/MEd8E7zxbYbgXM7yJZpdQC1yYVGlv8o07GqLh5p4N4PsqToN+IMBiD8rphEQ6 +hpUWYKGanECGHehP09/jM7XZLKin2kdDEgMwiDsKGkzOymduCtrW8alNc27k4wZk +lllAB4njycElZOEdC0Y1ngYkGNvnKvHKSk1VhO4QTT7ryqHhzSEu4rYrqy/IdAE6 +lSmjODT8UoinXm5gaafJL1DKBxtxar894yhzdA== +-----END CERTIFICATE----- diff --git a/tests/data/server.key b/tests/data/server.key new file mode 100644 index 0000000..a92d622 --- /dev/null +++ b/tests/data/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHqMokglXJ6eDR +44u1HhEN6JnBLM7YBl7DIWmP6Q8+/tt3JxFbbfM1B9eLdQjwEeRpbRpYO8KYQd/k +Pj66XN7wuwIrqZyv4RScRhyUdIin5zBbtOwWvmoWEquqNPBZCTADli7tCwhyXn5d +rwYOe5K7weDlgeVEeCfvWqCYQamVAE/HmZ6A+NYRrprp3Veq6xBmLNsHkv/pshLe +VXhSY3GRgRCZkaFA97lW79nHrLRDo912DbkYaXHQL3ixRoxCK4cmo6sYoTZiQnTX +6wtcSgB9aIA0DGoSU0me4Axd5e9AqALVuXX8NJMXvwCPTdi5jEkuCIIH9BkGzKAC +qgSXH+nBAgMBAAECggEAMIwE3MQ5Nl+RHA/bPkaJiIt8aUlrIQFJhOJIr+aQH7Xp +6Kq98HCHYKdfvGfQbMk48/6El5wuSIXJ+g49g+SFi9y43iN/dw+ASrBMjjdYrBTN +gBr5hd02X9gZ2AjHwSp3+4NGtToHy4FY8hAW5aob513wwHVpaRDbSvqLDoJ2yMTs +J8pq3m0ey/dNMetMZe81W/ETQoDmxPo/3uPaCcA8nB7G0Fh5kOiV8FVKhocAVqAB +Cw8Mf+arwe0s09u8RcxEeCN2kPFRi3HINM3rFfgKkWWRAtZclPSnpOMAdKi3GQb6 +Z6yee+I8pOpt6kFbLkSpBEe5Ve3vOBE8iE91IyXT/QKBgQD+putXadaRO928t5yn +9OKtaBfgN8jT+q29MlBRQKeOMlUih6XNaAHIhUCLRVOorPZod3qTyEYryI6AW4Sh +s6m5Ud8I1n7iy6Fh5+3die79RUXwFJBHJxTmuLNnwrOjKibOMRQhsRrgysABBS24 +QH+T2qZpeHxSCyWiWj3Kh7YnHQKBgQDIt1luiVgfjhJtO0sae8Jc1EQJ+MXj0dvP +IA7joG9eZVq8hw36qZYo1s5LWe6NbZHZwNC88o5stXH968qpWi27RP7PWrYd5yZa +3PdMBIhU1aVzPFHTk6ZAyM4hi5cwfQOrstGcJOLdu+2GtSWyy4Wd9tEVTw4NEQ03 +BnrEN7Z39QKBgQD7aVLKT5zecGt/yQtqKvSs3StNW+Xzmvdy5jyzq5CBbCHvYFsK +i5fPH/fUSFLLIlB4XRVw1/anfW9rPG+aseVsKG95q0NEqGQhZDjMU0TXWlAtMjAr +f2M8UrgVRf3Spmf/hCbrCI6Pxrx/hVxZH5yVHfbUBHdBDO5P9qYw0YG95QKBgAYD +2J5DZ2yqqUq0uC92/gAiLFfQKL4HD/tJDqkrqaq+htWXhoe4hVN7/Hqtm0SJwBEy +gg7nhfUkCuJ9KnmgxjYSf0Bfi42h27hlXtPcXumL7YVijWE1z4mpeuPudv1xAacn +mLiBMntKonei/ho5lyuAtgtZbyZdGHRJsWwn70PxAoGBAM5pK10bJna9UkoA4IOh +iLRfbex2ZIu3xNSuWLCaOgdDR8fiq/3SRlIwH8ZOtGSurYzYhbfWPC1uLZxfuI/x +gLSIaf/zEIxE0jXFXKAeMs4+yFEM0Gujb6LBQC4tGGcRGQhUqNLlXLn5TM+VJoaP +LR78ZZIHzmoHJRJBTb+tRnYi +-----END PRIVATE KEY----- diff --git a/tests/helpers.py b/tests/helpers.py index 64909ed..b8caa26 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,15 +1,11 @@ -try: - import unittest2 as unittest -except ImportError: - import unittest +import queue +import unittest +from unittest import mock -import mock - -from rabbitpy import channel, connection, events +from rabbitpy import channel, events class TestCase(unittest.TestCase): - def setUp(self): self.connection = mock.MagicMock('rabbitpy.connection.Connection') self.connection._io = mock.Mock() @@ -18,14 +14,18 @@ def setUp(self): self.connection._channel0 = mock.Mock() self.connection._channel0.properties = {} self.connection._events = events.Events() - self.connection._exceptions = connection.queue.Queue() + self.connection._exceptions = queue.Queue() self.connection.open = True self.connection.closed = False - self.channel = channel.Channel(1, {}, - self.connection._events, - self.connection._exceptions, - connection.queue.Queue(), - connection.queue.Queue(), 32768, - self.connection._io.write_trigger, - connection=self.connection) - self.channel._set_state(self.channel.OPEN) \ No newline at end of file + self.channel = channel.Channel( + 1, + {}, + self.connection._events, + self.connection._exceptions, + queue.Queue(), + queue.Queue(), + 32768, + self.connection._io.write_trigger, + connection=self.connection, + ) + self.channel._set_state(self.channel.OPEN) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py new file mode 100644 index 0000000..0e77763 --- /dev/null +++ b/tests/integration/test_connection.py @@ -0,0 +1,23 @@ +import logging +import os +import pathlib +import unittest + +import dotenv + +from rabbitpy import connection + +dotenv.load_dotenv(pathlib.Path(__file__).parent.parent.parent / '.env') + + +class ConnectionTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + logging.basicConfig(level=logging.DEBUG) + + @unittest.skipUnless( + os.environ.get('RABBITMQ_URL'), 'RABBITMQ_URL not set' + ) + def test_connection(self): + with connection.Connection(os.environ['RABBITMQ_URL']) as conn: + self.assertIsNotNone(conn) diff --git a/tests/integration_tests.py b/tests/integration_tests.py index b102a3b..861beef 100644 --- a/tests/integration_tests.py +++ b/tests/integration_tests.py @@ -1,21 +1,14 @@ import logging -import re import os +import re import threading import time -try: - import unittest2 as unittest -except ImportError: - import unittest -try: - from urllib import parse -except ImportError: - import urlparse as parse +import unittest import uuid +from urllib import parse import rabbitpy -from rabbitpy import exceptions -from rabbitpy import utils +from rabbitpy import exceptions, utils LOGGER = logging.getLogger(__name__) @@ -24,7 +17,6 @@ class ConfirmedPublishQueueLengthTest(unittest.TestCase): - ITERATIONS = 5 def setUp(self): @@ -53,7 +45,6 @@ def test_get_returns_expected_message(self): class PublishAndGetTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -67,12 +58,16 @@ def setUp(self): self.message_body = b'ABCDEFGHIJKLMNOPQRSTUVWXYZ' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.get') def tearDown(self): @@ -85,15 +80,17 @@ def test_get_returns_expected_message(self): msg = self.queue.get(True) self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) class PublishAndConsumeTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -107,12 +104,16 @@ def setUp(self): self.message_body = b'ABC1234567890' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.consume') def tearDown(self): @@ -125,10 +126,13 @@ def test_get_returns_expected_message(self): for msg in self.queue.consume(no_ack=True, prefetch=1): self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) break if utils.PYPY: @@ -136,7 +140,6 @@ def test_get_returns_expected_message(self): class PublishAndConsumeIteratorTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -150,12 +153,16 @@ def setUp(self): self.message_body = b'ABC1234567890' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.consume') def tearDown(self): @@ -168,10 +175,13 @@ def test_iterator_returns_expected_message(self): for msg in self.queue: self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) msg.ack() LOGGER.info('breaking out of iterator') @@ -182,7 +192,6 @@ def test_iterator_returns_expected_message(self): class PublishAndConsumeIteratorStopTest(unittest.TestCase): - PUBLISH_COUNT = 10 def setUp(self): @@ -199,14 +208,19 @@ def setUp(self): self.message_type = 'test' for iteration in range(0, self.PUBLISH_COUNT): - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) - self.msg.publish(self.exchange, - 'test.publish.consume {0}'.format(iteration)) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) + self.msg.publish( + self.exchange, f'test.publish.consume {iteration}' + ) def stop_consumer(self): LOGGER.info('Stopping the consumer') @@ -238,7 +252,6 @@ def test_iterator_exits_on_stop(self): class ConsumerTagTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -252,12 +265,16 @@ def setUp(self): self.message_body = b'ABC1234567890' self.message_type = 'test' - self.msg = rabbitpy.Message(self.channel, - self.message_body, - {'app_id': self.app_id, - 'message_id': str(uuid.uuid4()), - 'timestamp': int(time.time()), - 'message_type': self.message_type}) + self.msg = rabbitpy.Message( + self.channel, + self.message_body, + { + 'app_id': self.app_id, + 'message_id': str(uuid.uuid4()), + 'timestamp': int(time.time()), + 'message_type': self.message_type, + }, + ) self.msg.publish(self.exchange, 'test.publish.consume') def tearDown(self): @@ -270,10 +287,13 @@ def test_iterator_returns_expected_message(self): for msg in self.queue.consume(consumer_tag='test-tag'): self.assertEqual(msg.body, self.message_body) self.assertEqual(msg.properties['app_id'], self.app_id) - self.assertEqual(msg.properties['message_id'], - self.msg.properties['message_id'].decode('utf-8')) - self.assertEqual(msg.properties['timestamp'], - self.msg.properties['timestamp']) + self.assertEqual( + msg.properties['message_id'], + self.msg.properties['message_id'].decode('utf-8'), + ) + self.assertEqual( + msg.properties['timestamp'], self.msg.properties['timestamp'] + ) self.assertEqual(msg.properties['message_type'], self.message_type) msg.ack() LOGGER.info('breaking out of iterator') @@ -283,9 +303,7 @@ def test_iterator_returns_expected_message(self): self.assertFalse(self.queue.consuming) - class RedeliveredFlagTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -312,7 +330,6 @@ def test_redelivered_flag_is_set(self): class UnnamedQueueDeclareTest(unittest.TestCase): - def setUp(self): self.connection = rabbitpy.Connection(os.environ['RABBITMQ_URL']) self.channel = self.connection.channel() @@ -330,7 +347,6 @@ def test_declaring_nameless_queue(self): class SimpleCreateQueueTests(unittest.TestCase): - def test_create_queue(self): name = 'simple-create-queue' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -343,11 +359,11 @@ def test_create_queue(self): class SimpleCreateDirectExchangeTests(unittest.TestCase): - def test_create(self): name = 'direct-exchange-name' rabbitpy.create_direct_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.DirectExchange(channel, name) @@ -359,11 +375,11 @@ def test_raises_on_empty_name(self): class SimpleCreateFanoutExchangeTests(unittest.TestCase): - def test_create(self): name = 'fanout-exchange-name' rabbitpy.create_fanout_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.FanoutExchange(channel, name) @@ -375,11 +391,11 @@ def test_raises_on_empty_name(self): class SimpleCreateHeadersExchangeTests(unittest.TestCase): - def test_create(self): name = 'headers-exchange-name' rabbitpy.create_headers_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.HeadersExchange(channel, name) @@ -391,11 +407,11 @@ def test_raises_on_empty_name(self): class SimpleCreateTopicExchangeTests(unittest.TestCase): - def test_create(self): name = 'topic-exchange-name' rabbitpy.create_topic_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.TopicExchange(channel, name) @@ -407,25 +423,24 @@ def test_raises_on_empty_name(self): class SimpleDeleteExchangeTests(unittest.TestCase): - def test_delete(self): name = 'delete-exchange-name' rabbitpy.create_topic_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) rabbitpy.delete_exchange( - os.environ['RABBITMQ_URL'], exchange_name=name) + os.environ['RABBITMQ_URL'], exchange_name=name + ) with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.TopicExchange(channel, name) - self.assertRaises(exceptions.AMQPNotFound, - obj.declare, True) + self.assertRaises(exceptions.AMQPNotFound, obj.declare, True) def test_raises_on_empty_name(self): self.assertRaises(ValueError, rabbitpy.delete_exchange) class SimpleDeleteQueueTests(unittest.TestCase): - def test_delete(self): name = 'delete-queue-name' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -433,27 +448,28 @@ def test_delete(self): with rabbitpy.Connection(os.environ['RABBITMQ_URL']) as conn: with conn.channel() as channel: obj = rabbitpy.Queue(channel, name) - self.assertRaises(exceptions.AMQPNotFound, - obj.declare, True) + self.assertRaises(exceptions.AMQPNotFound, obj.declare, True) def test_raises_on_empty_name(self): self.assertRaises(ValueError, rabbitpy.delete_queue) class SimpleGetTests(unittest.TestCase): - def test_get_empty(self): name = 'queue-name-get' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) self.assertIsNone( - rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name)) + rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) + ) rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) def test_get_msg(self): body = b'test-body' name = 'queue-name-get' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) - rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, body=body) + rabbitpy.publish( + os.environ['RABBITMQ_URL'], routing_key=name, body=body + ) result = rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) self.assertEqual(result.body, body) rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -463,30 +479,39 @@ def test_raises_on_empty_name(self): class SimplePublishTests(unittest.TestCase): - def test_publish_with_confirm(self): body = b'test-body' name = 'simple-publish' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) self.assertTrue( - rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, - body=body, confirm=True)) + rabbitpy.publish( + os.environ['RABBITMQ_URL'], + routing_key=name, + body=body, + confirm=True, + ) + ) result = rabbitpy.get(os.environ['RABBITMQ_URL'], queue_name=name) self.assertEqual(result.body, body) rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) class SimpleConsumeTests(unittest.TestCase): - def test_publish_with_confirm(self): body = b'test-body' name = 'simple-consume-tests' rabbitpy.create_queue(os.environ['RABBITMQ_URL'], queue_name=name) self.assertTrue( - rabbitpy.publish(os.environ['RABBITMQ_URL'], routing_key=name, - body=body, confirm=True)) + rabbitpy.publish( + os.environ['RABBITMQ_URL'], + routing_key=name, + body=body, + confirm=True, + ) + ) for message in rabbitpy.consume( - os.environ['RABBITMQ_URL'], queue_name=name, no_ack=True): + os.environ['RABBITMQ_URL'], queue_name=name, no_ack=True + ): self.assertEqual(message.body, body) break rabbitpy.delete_queue(os.environ['RABBITMQ_URL'], queue_name=name) @@ -495,13 +520,12 @@ def test_raises_on_empty_name(self): try: for _msg in rabbitpy.consume(os.environ['RABBITMQ_URL']): break - assert False, 'Did not raise ValueError' + raise AssertionError('Did not raise ValueError') except ValueError: assert True class Issue119TestCase(unittest.TestCase): - def test_exception_is_raised(self): conn = rabbitpy.Connection(os.environ['RABBITMQ_URL']) channel = conn.channel() @@ -511,23 +535,18 @@ def test_exception_is_raised(self): msg.publish(exchange='invalid', mandatory=True) - class AccessDeniedDuringConnectionTests(unittest.TestCase): - def test_exception_is_raised(self): url_parts = parse.urlsplit(os.environ['RABBITMQ_URL']) - netloc = '{}:{}@{}:{}'.format( - uuid.uuid4().hex, - uuid.uuid4().hex, - url_parts.hostname, - url_parts.port or 5672, + netloc = f'{uuid.uuid4().hex}:{uuid.uuid4().hex}@{url_parts.hostname}:{url_parts.port or 5672}' + url = parse.urlunsplit( + ( + url_parts.scheme, + netloc, + url_parts.path, + url_parts.query, + url_parts.fragment, + ) ) - url = parse.urlunsplit(( - url_parts.scheme, - netloc, - url_parts.path, - url_parts.query, - url_parts.fragment, - )) with self.assertRaises(exceptions.AMQPAccessRefused): rabbitpy.Connection(url) diff --git a/tests/mixins.py b/tests/mixins.py new file mode 100644 index 0000000..d7dd0d2 --- /dev/null +++ b/tests/mixins.py @@ -0,0 +1,11 @@ +import pathlib + +import dotenv + +_ENV_FILE = pathlib.Path(__file__).parent.parent / '.env' + + +class EnvironmentVariableMixin: + @classmethod + def setUpClass(cls): + dotenv.load_dotenv(_ENV_FILE) diff --git a/tests/test_amqp.py b/tests/test_amqp.py index 6fe716b..a1cd7f2 100644 --- a/tests/test_amqp.py +++ b/tests/test_amqp.py @@ -2,19 +2,19 @@ Test the rabbitpy.amqp class """ -import mock -from rabbitpy import amqp, base, channel +from unittest import mock +from rabbitpy import amqp from tests import helpers -class BasicAckTests(helpers.TestCase): +class BasicAckTests(helpers.TestCase): def test_basic_ack_invokes_write_frame(self): with mock.patch.object(self.channel, 'write_frame') as method: obj = amqp.AMQP(self.channel) obj.basic_ack(123, True) - args, kwargs = method.call_args + args, _kwargs = method.call_args self.assertEqual(len(args), 1) self.assertEqual(args[0].delivery_tag, 123) self.assertEqual(args[0].multiple, True) diff --git a/tests/events_tests.py b/tests/test_events.py similarity index 71% rename from tests/events_tests.py rename to tests/test_events.py index 75580cf..ac933d2 100644 --- a/tests/events_tests.py +++ b/tests/test_events.py @@ -2,18 +2,15 @@ Test the rabbitpy events class """ -import mock + import threading -try: - import unittest2 as unittest -except ImportError: - import unittest +import unittest +from unittest import mock from rabbitpy import events class BaseEventsTest(unittest.TestCase): - def setUp(self): self._events = events.Events() @@ -22,7 +19,6 @@ def tearDown(self): class EventClearTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.clear(0)) @@ -35,19 +31,16 @@ def test_unset_event_returns_false(self): class EventInitTests(BaseEventsTest): - def test_all_events_created(self): - try: - cls = threading._Event - except AttributeError: - cls = threading.Event for event in events.DESCRIPTIONS.keys(): - self.assertIsInstance(self._events._events[event], cls, - type(self._events._events[event])) + self.assertIsInstance( + self._events._events[event], + threading.Event, + type(self._events._events[event]), + ) class EventIsSetTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.is_set(0)) @@ -60,7 +53,6 @@ def test_unset_event_returns_false(self): class EventSetTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.set(0)) @@ -73,24 +65,15 @@ def test_already_set_event_returns_false(self): class EventWaitTests(BaseEventsTest): - def test_invalid_event(self): self.assertIsNone(self._events.wait(0)) def test_blocking_wait_returns_true(self): - try: - cls = threading._Event - except AttributeError: - cls = threading.Event - with mock.patch.object(cls, 'wait') as mock_method: - mock_method.return_value = True + with mock.patch.object(threading.Event, 'wait') as wait: + wait.return_value = True self.assertTrue(self._events.wait(events.CHANNEL0_CLOSED)) def test_blocking_wait_returns_false(self): - try: - cls = threading._Event - except AttributeError: - cls = threading.Event - with mock.patch.object(cls, 'wait') as mock_method: - mock_method.return_value = False + with mock.patch.object(threading.Event, 'wait') as wait: + wait.return_value = False self.assertFalse(self._events.wait(events.CHANNEL0_CLOSED, 1)) diff --git a/tests/test_exchange.py b/tests/test_exchange.py index f1007c1..eaf0eec 100644 --- a/tests/test_exchange.py +++ b/tests/test_exchange.py @@ -2,47 +2,51 @@ Test the rabbitpy.exchange classes """ -import mock -from pamqp import specification -from rabbitpy import exchange +from unittest import mock + +from pamqp import commands as specification +from rabbitpy import exchange from tests import helpers class TxTests(helpers.TestCase): - @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_declare(self, rpc): rpc.return_value = specification.Exchange.DeclareOk obj = exchange.Exchange(self.channel, 'foo') obj.declare() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Declare + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_delete(self, rpc): rpc.return_value = specification.Exchange.DeleteOk obj = exchange.Exchange(self.channel, 'foo') obj.delete() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Delete) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Delete + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_bind(self, rpc): rpc.return_value = specification.Exchange.BindOk obj = exchange.Exchange(self.channel, 'foo') obj.bind('a', 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Bind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Bind + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_unbind(self, rpc): rpc.return_value = specification.Exchange.UnbindOk obj = exchange.Exchange(self.channel, 'foo') obj.unbind('a', 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Unbind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Unbind + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_bind_obj(self, rpc): @@ -51,8 +55,9 @@ def test_bind_sends_exchange_bind_obj(self, rpc): val = mock.Mock() val.name = 'bar' obj.bind(val, 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Bind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Bind + ) @mock.patch('rabbitpy.exchange.Exchange._rpc') def test_bind_sends_exchange_unbind_obj(self, rpc): @@ -61,33 +66,30 @@ def test_bind_sends_exchange_unbind_obj(self, rpc): val = mock.Mock() val.name = 'bar' obj.unbind(val, 'b') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Exchange.Unbind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Exchange.Unbind + ) class DirectExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.DirectExchange(self.channel, 'direct-test') self.assertEqual(obj.type, 'direct') class FanoutExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.FanoutExchange(self.channel, 'fanout-test') self.assertEqual(obj.type, 'fanout') class HeadersExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.HeadersExchange(self.channel, 'headers-test') self.assertEqual(obj.type, 'headers') class TopicExchangeCreationTests(helpers.TestCase): - def test_init_creates_direct_exchange(self): obj = exchange.TopicExchange(self.channel, 'topic-test') self.assertEqual(obj.type, 'topic') diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..a494790 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,552 @@ +import asyncio +import logging +import pathlib +import queue +import random +import socket +import ssl +import struct +import typing +import unittest +from unittest import mock + +import pamqp.frame +from pamqp import commands, constants, header + +import rabbitpy.events +from rabbitpy import events, exceptions, io, url_parser +from rabbitpy.url_parser import SslOptions +from tests import mixins + +_DEFAULT_SSL_OPTIONS = SslOptions( + check_hostname=True, + cafile=None, + capath=None, + certfile=None, + keyfile=None, + verify=None, +) + +LOGGER = logging.getLogger(__name__) + +DATA_PATH = pathlib.Path(__file__).parent.resolve() / 'data' + + +class TestCase(unittest.TestCase): + def setUp(self): + """Set up a common IO instance for tests.""" + self.events = events.Events() + self.exceptions: queue.Queue[Exception] = queue.Queue() + self.read_queue: queue.Queue[io.PamqpFrame] = queue.Queue() + self.write_queue: queue.Queue[io.PamqpFrame] = queue.Queue() + + def assertExceptionAdded(self, expectation): + error = self.exceptions.get(False) + self.assertIsInstance(error, expectation) + self.assertTrue(self.events.is_set(events.EXCEPTION_RAISED)) + + def tearDown(self): + try: + exc = self.exceptions.get(False) + except queue.Empty: + pass + else: + LOGGER.exception('Uncaught exception: %r', exc) + raise exc + + +class TestIO(TestCase): + def setUp(self): + """Set up a common IO instance for tests.""" + super().setUp() + self.io = io.IO( + 'localhost', + 5672, + False, + _DEFAULT_SSL_OPTIONS, + self.events, + self.exceptions, + ) + self.io.add_channel(0, self.read_queue) + + def test_initialization(self): + """Test IO class initialization.""" + self.assertEqual(self.io._host, 'localhost') + self.assertEqual(self.io._port, 5672) + self.assertFalse(self.io._use_ssl) + self.assertIsInstance(self.io._channels, dict) + self.assertIsInstance(self.io._events, events.Events) + self.assertIsInstance(self.io._exceptions, queue.Queue) + self.assertFalse(self.io.is_connected) + + def test_on_data_received(self): + data_in = ( + b'\x01\x00\x00\x00\x00\x01G\x00\n\x00\n\x00\t' + b'\x00\x00\x01"\x0ccapabilitiesF\x00\x00\x00X' + b'\x12publisher_confirmst\x01\x1aexchange_exc' + b'hange_bindingst\x01\nbasic.nackt\x01\x16con' + b'sumer_cancel_notifyt\x01\tcopyrightS\x00' + b'\x00\x00$Copyright (C) 2007-2011 VMware, I' + b'nc.\x0binformationS\x00\x00\x005Licensed u' + b'nder the MPL. See http://www.rabbitmq.com' + b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' + b'productS\x00\x00\x00\x08RabbitMQ\x07versio' + b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce' + ) + remaining, channel, frame, count = self.io._on_data_received(data_in) + self.assertEqual(remaining, b'') + self.assertEqual(channel, 0) + self.assertIsInstance(frame, commands.Connection.Start) + assert isinstance(frame, commands.Connection.Start) + self.assertEqual(frame.locales, 'en_US') + self.assertEqual(count, 335) + + def test_on_data_received_empty(self): + remaining, channel, frame, byte_count = self.io._on_data_received(b'') + self.assertEqual(remaining, b'') + self.assertIsNone(channel) + self.assertIsNone(frame) + self.assertEqual(byte_count, 0) + + def test_on_data_received_error(self): + payload = struct.pack('>L', 42949) + data_in = b''.join( + [ + struct.pack('>BHI', 1, 0, len(payload)), + payload, + constants.FRAME_END_CHAR, + ] + ) + remaining, channel, frame, count = self.io._on_data_received(data_in) + self.assertEqual(remaining, data_in) + self.assertIsNone(channel) + self.assertIsNone(frame) + self.assertEqual(count, 0) + + def test_on_error(self): + self.io._on_error(OSError('Foo')) + self.assertExceptionAdded(exceptions.ConnectionException) + + def test_on_error_already_closed(self): + self.events.set(events.SOCKET_CLOSED) + self.io._on_error(OSError('Foo')) + with self.assertRaises(queue.Empty): + self.exceptions.get(False) + + def test_connect_failure(self): + instance = io.IO( + 'localhost', + random.randint(1024, 32768), + False, + _DEFAULT_SSL_OPTIONS, + self.events, + self.exceptions, + ) + instance.start() + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 1) + self.assertFalse(connected, 'Should not have connected') + self.assertExceptionAdded(exceptions.ConnectionException) + self.assertFalse(self.io.is_connected) + + def test_disconnected_close(self): + self.io.close() + + def test_write_frame_when_closed(self): + self.io.write_frame(0, commands.Connection.Start()) + self.assertExceptionAdded(exceptions.ConnectionException) + + def test_get_ssl_context(self): + self.io._use_ssl = True + self.io._ssl_options = _DEFAULT_SSL_OPTIONS + context = self.io._get_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + assert isinstance(context, ssl.SSLContext) + self.assertTrue(context.check_hostname) + + def test_get_ssl_context_verify_none(self): + self.io._use_ssl = True + self.io._ssl_options = SslOptions( + check_hostname=False, + cafile=None, + capath=None, + certfile=None, + keyfile=None, + verify=ssl.CERT_NONE, + ) + context = self.io._get_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + assert isinstance(context, ssl.SSLContext) + self.assertFalse(context.check_hostname) + + def test_get_ssl_context_certs(self): + self.io._use_ssl = True + self.io._ssl_options = SslOptions( + check_hostname=True, + cafile=str(DATA_PATH / 'ca.crt'), + capath=None, + certfile=str(DATA_PATH / 'client.crt'), + keyfile=str(DATA_PATH / 'client.key'), + verify=ssl.CERT_REQUIRED, + ) + context = self.io._get_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + assert isinstance(context, ssl.SSLContext) + self.assertTrue(context.check_hostname) + self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED) + + +class MockServer(asyncio.Protocol): + instances: typing.ClassVar[list['MockServer']] = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._expectation: bytes | None = None + self._response: bytes | None = None + self._transport: asyncio.BaseTransport | None = None + MockServer.instances.append(self) + + def connection_lost(self, exc: Exception | None) -> None: + MockServer.instances.remove(self) + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self._transport = transport + LOGGER.debug( + 'Connection from %r', transport.get_extra_info('peername') + ) + + def data_received(self, data: bytes) -> None: + LOGGER.debug('Received %r', data) + if self._transport is None: + return + if self._expectation and data != self._expectation: + self._transport.write(b'Bad data: ' + data) # type: ignore[attr-defined] + return + if self._response: + self._transport.write(self._response) # type: ignore[attr-defined] + + # Connection.CloseOk + if data == b'\x01\x00\x00\x00\x00\x00\x04\x00\n\x003\xce': + LOGGER.debug('Closing connection') + self._transport.close() + + def set_expectation(self, expectation: bytes) -> None: + self._expectation = expectation + + def set_response(self, response: bytes) -> None: + self._response = response + + +class MockServerTestCase( + mixins.EnvironmentVariableMixin, TestCase, unittest.IsolatedAsyncioTestCase +): + async def asyncSetUp(self): + await super().asyncSetUp() + self.server = await self._create_server() + args = self._get_io_args() + LOGGER.debug('Connection args: %r', args) + self.io = io.IO( + args['host'], + args['port'], + args['ssl'], + args['ssl_options'], + self.events, + self.exceptions, + ) + self.io.add_channel(0, self.read_queue) + self.io.add_channel(1, self.read_queue) + + async def asyncTearDown(self): + self.server.close() + await super().asyncTearDown() + + async def get_mock_server(self, retries=10, delay=0.1): + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) + self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + assert self.io._socket is not None + socket_local = self.io._socket.getsockname() # (address, port) + socket_remote = self.io._socket.getpeername() # (address, port) + for _attempt in range(retries): + for instance in MockServer.instances: + if instance._transport: + server = instance._transport.get_extra_info('sockname') + peer = instance._transport.get_extra_info('peername') + if socket_local == peer and socket_remote == server: + return instance # Return the matched MockServer + await asyncio.sleep(delay) + raise AssertionError('No MockServer found') + + async def write_frame( + self, channel: int, frame: pamqp.frame.FrameTypes + ) -> None: + self.io.write_frame(channel, frame) + await asyncio.sleep(0.1) + + async def write_protocol_header(self) -> None: + await self.write_frame(0, header.ProtocolHeader()) + self.assertEqual(self.io.bytes_written, 8) + + def _get_io_args(self): + return url_parser.parse(f'amqp://127.0.0.1:{self._server_port}') + + @staticmethod + async def _create_server(): + loop = asyncio.get_running_loop() + return await loop.create_server(MockServer, '127.0.0.1') + + @property + def _server_port(self): + return self.server.sockets[0].getsockname()[1] + + +class MockServerConnectedTestCase(MockServerTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.io.start() + connected = self.events.wait(rabbitpy.events.SOCKET_OPENED, 3) + self.assertTrue(connected, 'Timeout waiting for SOCKET_OPENED event') + self.assertTrue(self.io.is_connected) + + async def test_on_read_ready(self): + mock_server = await self.get_mock_server() + mock_server.set_expectation(b'AMQP\x00\x00\t\x01') + mock_server.set_response( + b'\x01\x00\x00\x00\x00\x01G\x00\n\x00\n\x00\t' + b'\x00\x00\x01"\x0ccapabilitiesF\x00\x00\x00X' + b'\x12publisher_confirmst\x01\x1aexchange_exc' + b'hange_bindingst\x01\nbasic.nackt\x01\x16con' + b'sumer_cancel_notifyt\x01\tcopyrightS\x00' + b'\x00\x00$Copyright (C) 2007-2011 VMware, I' + b'nc.\x0binformationS\x00\x00\x005Licensed u' + b'nder the MPL. See http://www.rabbitmq.com' + b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' + b'productS\x00\x00\x00\x08RabbitMQ\x07versio' + b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce' + ) + + await self.write_protocol_header() + + # Block until the IO thread has dispatched the frame; this guarantees + # that _buffer and bytes_received reflect the fully-processed state + # regardless of event-loop scheduling delays (notably slow on 3.12). + frame = self.io._channels[0].get(True, 3) + self.assertIsInstance(frame, commands.Connection.Start) + assert isinstance(frame, commands.Connection.Start) + self.assertEqual(frame.locales, 'en_US') + + self.assertEqual(self.io._buffer, b'') + self.assertEqual(self.io.bytes_received, 335) + + async def test_on_data_received_multiple_frames(self): + mock_server = await self.get_mock_server() + mock_server.set_expectation(b'AMQP\x00\x00\t\x01') + mock_server.set_response( + b'\x01\x00\x00\x00\x00\x01G\x00\n\x00\n\x00\t' + b'\x00\x00\x01"\x0ccapabilitiesF\x00\x00\x00X' + b'\x12publisher_confirmst\x01\x1aexchange_exc' + b'hange_bindingst\x01\nbasic.nackt\x01\x16con' + b'sumer_cancel_notifyt\x01\tcopyrightS\x00' + b'\x00\x00$Copyright (C) 2007-2011 VMware, I' + b'nc.\x0binformationS\x00\x00\x005Licensed u' + b'nder the MPL. See http://www.rabbitmq.com' + b'/\x08platformS\x00\x00\x00\nErlang/OTP\x07' + b'productS\x00\x00\x00\x08RabbitMQ\x07versio' + b'nS\x00\x00\x00\x052.6.1\x00\x00\x00\x0ePLA' + b'IN AMQPLAIN\x00\x00\x00\x05en_US\xce\x01\x00' + b'\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce' + ) + + await self.write_protocol_header() + + # Block on both frames before checking bytes_received so that the IO + # thread has fully processed the response before we inspect state. + frame = self.io._channels[0].get(True, 3) + self.assertIsInstance(frame, commands.Connection.Start) + assert isinstance(frame, commands.Connection.Start) + self.assertEqual(frame.locales, 'en_US') + + frame = self.io._channels[1].get(True, 3) + self.assertIsInstance(frame, commands.Tx.RollbackOk) + + self.assertEqual(self.io.bytes_received, 347) + + async def test_on_data_received_remaining_buffer(self): + mock_server = await self.get_mock_server() + mock_server.set_expectation(b'AMQP\x00\x00\t\x01') + mock_server.set_response( + b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00' + ) + + await self.write_protocol_header() + + # Block until the IO thread has dispatched the frame before checking + # _buffer and bytes_received to avoid a race on slow event loops. + frame = self.read_queue.get(True, 3) + self.assertIsInstance(frame, commands.Tx.RollbackOk) + + self.assertEqual(self.io._buffer, b'\x01\x00\x00') + self.assertEqual(self.io.bytes_received, 12) + + async def test_remote_name(self): + mock_server = await self.get_mock_server() + assert mock_server._transport is not None + remote = mock_server._transport.get_extra_info('sockname') + assert self.io._socket is not None + local = self.io._socket.getsockname() + self.assertEqual( + self.io.remote_name, + f'{local[0]}:{local[1]} -> {remote[0]}:{remote[1]}', + ) + + async def test_close(self): + _mock_server = await self.get_mock_server() + self.io.close() + self.assertTrue(self.events.is_set(events.SOCKET_CLOSED)) + self.assertFalse(self.io.is_connected) + + +class EdgeCaseMockServerTestCase(MockServerTestCase): + @mock.patch('socket.getaddrinfo') + async def test_error_on_getaddrinfo(self, mock_getaddrinfo): + mock_getaddrinfo.side_effect = OSError('Foo') + self.io.start() + await asyncio.sleep(0.1) + self.assertFalse(self.io.is_connected) + self.assertExceptionAdded(exceptions.ConnectionException) + + async def test_write_ready_socket_timeout(self): + with mock.patch('socket.socket.sendall') as mock_sendall: + mock_sendall.side_effect = socket.timeout + self.io.start() + await asyncio.sleep(0.1) + self.assertEqual(self.io.bytes_written, 0) + + self.io.write_frame(1, commands.Tx.RollbackOk()) + self.io._on_write_ready() + self.assertEqual(self.io.bytes_written, 0) + + # a timeout should have the frame placed back on the top of stack + value = self.io._write_buffer.popleft() + expectation = pamqp.frame.marshal(commands.Tx.RollbackOk(), 1) + self.assertEqual(value, expectation) + + async def test_write_ready_socket_oserror(self): + with mock.patch('socket.socket.sendall') as mock_sendall: + self.io.start() + await asyncio.sleep(0.1) + mock_sendall.side_effect = OSError('Foo') + self.io.write_frame(1, commands.Tx.RollbackOk()) + self.io._on_write_ready() + + self.assertEqual(self.io.bytes_written, 0) + + # a timeout should have the frame placed back on the top of stack + value = self.io._write_buffer.popleft() + expectation = pamqp.frame.marshal(commands.Tx.RollbackOk(), 1) + self.assertEqual(value, expectation) + + self.assertExceptionAdded(exceptions.ConnectionException) + + # The mock context has exited; sendall no longer raises. Yield to let + # the IO thread's writer callback (which fires continuously on Python + # 3.11) settle, then drain any duplicate ConnectionExceptions that may + # have been added before the write buffer was emptied above. + await asyncio.sleep(0.1) + while not self.exceptions.empty(): + dup = self.exceptions.get_nowait() + self.assertIsInstance(dup, exceptions.ConnectionException) + + async def test_write_ready_socket_errno_35(self): + with mock.patch('socket.socket.sendall') as mock_sendall: + self.io.start() + await asyncio.sleep(0.1) + mock_sendall.side_effect = Error35(35, 'Mock Error 35') + self.io.write_frame(1, commands.Tx.RollbackOk()) + self.io._on_write_ready() + self.assertEqual(self.io.bytes_written, 0) + + # a timeout should have the frame placed back on the top of stack + value = self.io._write_buffer.popleft() + expectation = pamqp.frame.marshal(commands.Tx.RollbackOk(), 1) + self.assertEqual(value, expectation) + + # No exception should have been added + with self.assertRaises(queue.Empty): + self.exceptions.get(False) + + async def test_run_asyncio_cancelled(self): + with mock.patch.object(self.io, '_run') as mock_run: + mock_run.side_effect = asyncio.CancelledError() + self.io.start() + self.assertFalse(self.io.is_connected) + + async def test_run_oserror(self): + with mock.patch.object(self.io, '_run') as mock_run: + mock_run.side_effect = OSError('Mock Error') + self.io.start() + self.assertFalse(self.io.is_connected) + await asyncio.sleep(0.1) + self.assertExceptionAdded(exceptions.ConnectionException) + + +class SSLMockServerTestCase(MockServerTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.io.start() + # Yield to the event loop so the in-process SSL mock server can + # complete its handshake (blocking wait() would deadlock the loop). + for _ in range(50): + if self.events.is_set(rabbitpy.events.SOCKET_OPENED): + break + await asyncio.sleep(0.1) + self.assertTrue( + self.events.is_set(rabbitpy.events.SOCKET_OPENED), + 'Timeout waiting for SOCKET_OPENED event', + ) + self.assertTrue(self.io.is_connected) + + @staticmethod + async def _create_server(): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_verify_locations(DATA_PATH / 'ca.crt') + context.load_cert_chain( + DATA_PATH / 'server.crt', DATA_PATH / 'server.key' + ) + loop = asyncio.get_running_loop() + server = await loop.create_server(MockServer, '127.0.0.1', ssl=context) + LOGGER.debug( + 'Mock server running on: %s', server.sockets[0].getsockname() + ) + return server + + def _get_io_args(self): + return url_parser.parse( + f'amqps://127.0.0.1:{self._server_port}?' + f'ssl_check_hostname=false&ssl_verify=ignore&' + f'cafile=tests%2Fdata%2Fca.crt' + ) + + async def test_on_data_received(self): + mock_server = await self.get_mock_server() + mock_server.set_expectation(b'AMQP\x00\x00\t\x01') + mock_server.set_response( + b'\x01\x00\x01\x00\x00\x00\x04\x00Z\x00\x1f\xce\x01\x00\x00' + ) + + await self.write_protocol_header() + + # Get the frame first — blocks until the IO thread has processed the + # full response, avoiding a race on _buffer and bytes_received. + frame = self.read_queue.get(True, 3) + self.assertIsInstance(frame, commands.Tx.RollbackOk) + self.assertEqual(self.io._buffer, b'\x01\x00\x00') + self.assertEqual(self.io.bytes_received, 12) + + +class Error35(OSError): + """Mock Exception for socket.error errno=35""" + + def __init__(self, errno, *args): + super().__init__(*args) + self.errno = errno diff --git a/tests/test_message.py b/tests/test_message.py index 96b7ff7..5aebc91 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,33 +1,27 @@ - # -*- coding: utf-8 -*- """ Test the rabbitpy.message.Message class """ + import datetime import json import logging import time import uuid +from unittest import mock -import mock -from pamqp import body -from pamqp import header -from pamqp import specification - -from rabbitpy import channel -from rabbitpy import exceptions -from rabbitpy import exchange -from rabbitpy import message +from pamqp import body, header +from pamqp import commands as specification +from rabbitpy import exceptions, exchange, message from tests import helpers logging.basicConfig(level=logging.DEBUG) class TestCreation(helpers.TestCase): - def setUp(self): - super(TestCreation, self).setUp() + super().setUp() self.body = uuid.uuid4() self.msg = message.Message(self.channel, self.body, opinionated=True) @@ -42,9 +36,8 @@ def test_message_message_id_property_set(self): class TestCreationWithDictBody(helpers.TestCase): - def setUp(self): - super(TestCreationWithDictBody, self).setUp() + super().setUp() self.body = {'foo': str(uuid.uuid4())} self.msg = message.Message(self.channel, self.body) @@ -52,83 +45,90 @@ def test_message_body(self): self.assertEqual(self.msg.body, json.dumps(self.body)) def test_message_content_type_is_set(self): - self.assertEqual(self.msg.properties['content_type'], - 'application/json') + self.assertEqual( + self.msg.properties['content_type'], 'application/json' + ) class TestCreationWithStructTimeTimestamp(helpers.TestCase): - def setUp(self): - super(TestCreationWithStructTimeTimestamp, self).setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': time.localtime()}) + super().setUp() + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': time.localtime()} + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithFloatTimestamp(helpers.TestCase): - def setUp(self): - super(TestCreationWithFloatTimestamp, self).setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': time.time()}) + super().setUp() + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': time.time()} + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithIntTimestamp(helpers.TestCase): - def setUp(self): - super(TestCreationWithIntTimestamp, self).setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': int(time.time())}) + super().setUp() + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': int(time.time())} + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithInvalidTimestampType(helpers.TestCase): - def test_message_timestamp_property_is_datetime(self): - self.assertRaises(TypeError, - message.Message, - self.channel, - str(uuid.uuid4()), - {'timestamp': ['Ohai']}) + self.assertRaises( + TypeError, + message.Message, + self.channel, + str(uuid.uuid4()), + {'timestamp': ['Ohai']}, + ) class TestCreationWithNoneTimestamp(helpers.TestCase): - def setUp(self): - super(TestCreationWithNoneTimestamp, self).setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': None}) + super().setUp() + self.msg = message.Message( + self.channel, str(uuid.uuid4()), {'timestamp': None} + ) def test_message_timestamp_property_is_datetime(self): self.assertIsNone(self.msg.properties['timestamp']) class TestCreationWithStrTimestamp(helpers.TestCase): - def setUp(self): - super(TestCreationWithStrTimestamp, self).setUp() - self.msg = message.Message(self.channel, str(uuid.uuid4()), - {'timestamp': str(int(time.time()))}) + super().setUp() + self.msg = message.Message( + self.channel, + str(uuid.uuid4()), + {'timestamp': str(int(time.time()))}, + ) def test_message_timestamp_property_is_datetime(self): - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestCreationWithDictBodyAndProperties(helpers.TestCase): - def setUp(self): - super(TestCreationWithDictBodyAndProperties, self).setUp() + super().setUp() self.body = {'foo': str(uuid.uuid4())} self.msg = message.Message(self.channel, self.body, {'app_id': 'foo'}) @@ -136,14 +136,14 @@ def test_message_body(self): self.assertEqual(self.msg.body, json.dumps(self.body)) def test_message_content_type_is_set(self): - self.assertEqual(self.msg.properties['content_type'], - 'application/json') + self.assertEqual( + self.msg.properties['content_type'], 'application/json' + ) class TestNonOpinionatedCreation(helpers.TestCase): - def setUp(self): - super(TestNonOpinionatedCreation, self).setUp() + super().setUp() self.body = str(uuid.uuid4()) self.msg = message.Message(self.channel, self.body) @@ -158,23 +158,24 @@ def test_message_timestamp_property_is_not_set(self): class TestWithPropertiesCreation(helpers.TestCase): - def setUp(self): - super(TestWithPropertiesCreation, self).setUp() + super().setUp() self.body = uuid.uuid4() - self.props = {'app_id': b'Foo', - 'content_type': b'application/json', - 'content_encoding': b'gzip', - 'correlation_id': str(uuid.uuid4()), - 'delivery_mode': 2, - 'expiration': int(time.time()) + 10, - 'headers': {'foo': 'bar'}, - 'message_id': str(uuid.uuid4()), - 'message_type': b'TestCreation', - 'priority': 9, - 'reply_to': b'none', - 'timestamp': datetime.datetime.utcnow(), - 'user_id': b'guest'} + self.props = { + 'app_id': b'Foo', + 'content_type': b'application/json', + 'content_encoding': b'gzip', + 'correlation_id': str(uuid.uuid4()), + 'delivery_mode': 2, + 'expiration': int(time.time()) + 10, + 'headers': {'foo': 'bar'}, + 'message_id': str(uuid.uuid4()), + 'message_type': b'TestCreation', + 'priority': 9, + 'reply_to': b'none', + 'timestamp': datetime.datetime.now(tz=datetime.UTC), + 'user_id': b'guest', + } self.msg = message.Message(self.channel, self.body, dict(self.props)) def test_message_body(self): @@ -185,16 +186,17 @@ def test_message_properties_match(self): class TestInvalidPropertyHandling(helpers.TestCase): - def test_invalid_property_raises_key_error(self): - self.assertRaises(KeyError, - message.Message, - self.channel, - str(uuid.uuid4()), {'invalid': True}) + self.assertRaises( + KeyError, + message.Message, + self.channel, + str(uuid.uuid4()), + {'invalid': True}, + ) class TestDeliveredMessageObject(helpers.TestCase): - BODY = '{"foo": "bar", "val": 1}' PROPERTIES = {'message_type': 'test'} CONSUMER_TAG = 'ctag0' @@ -204,12 +206,14 @@ class TestDeliveredMessageObject(helpers.TestCase): ROUTING_KEY = 'test-routing-key' def setUp(self): - super(TestDeliveredMessageObject, self).setUp() - self.method = specification.Basic.Deliver(self.CONSUMER_TAG, - self.DELIVERY_TAG, - self.REDELIVERED, - self.EXCHANGE, - self.ROUTING_KEY) + super().setUp() + self.method = specification.Basic.Deliver( + self.CONSUMER_TAG, + self.DELIVERY_TAG, + self.REDELIVERED, + self.EXCHANGE, + self.ROUTING_KEY, + ) self.msg = message.Message(self.channel, self.BODY, self.PROPERTIES) self.msg.method = self.method self.msg.name = self.method.name @@ -244,8 +248,7 @@ def test_ack_channel_write_frame_delivery_tag_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: self.msg.ack() frame_value = wframe.mock_calls[0][1][0] - self.assertEqual(frame_value.delivery_tag, - self.DELIVERY_TAG) + self.assertEqual(frame_value.delivery_tag, self.DELIVERY_TAG) def test_ack_channel_write_frame_multiple_false_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: @@ -274,8 +277,7 @@ def test_nack_channel_write_frame_delivery_tag_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: self.msg.nack() frame_value = wframe.mock_calls[0][1][0] - self.assertEqual(frame_value.delivery_tag, - self.DELIVERY_TAG) + self.assertEqual(frame_value.delivery_tag, self.DELIVERY_TAG) def test_nack_channel_write_frame_requeue_false_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: @@ -316,8 +318,7 @@ def test_reject_channel_write_frame_delivery_tag_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: self.msg.reject() frame_value = wframe.mock_calls[0][1][0] - self.assertEqual(frame_value.delivery_tag, - self.DELIVERY_TAG) + self.assertEqual(frame_value.delivery_tag, self.DELIVERY_TAG) def test_reject_channel_write_frame_requeue_false_value(self): with mock.patch('rabbitpy.channel.Channel.write_frame') as wframe: @@ -333,13 +334,10 @@ def test_reject_channel_write_frame_requeue_true_value(self): class TestNonDeliveredMessageObject(helpers.TestCase): - - BODY = {'foo': str(uuid.uuid4()), - 'bar': 'baz', - 'qux': 1} + BODY = {'foo': str(uuid.uuid4()), 'bar': 'baz', 'qux': 1} def setUp(self): - super(TestNonDeliveredMessageObject, self).setUp() + super().setUp() self.body = self.BODY self.msg = message.Message(self.channel, self.body, {'app_id': 'foo'}) @@ -370,48 +368,52 @@ def test_coerce_property_str_to_int(self): def test_coerce_property_str_to_empty_dict(self): self.msg.properties['headers'] = '9' self.msg._coerce_properties() - self.assertDictEqual(self.msg.properties['headers'], dict()) + self.assertDictEqual(self.msg.properties['headers'], {}) def test_coerce_property_str_timestamp(self): self.msg.properties['timestamp'] = str(int(time.time())) self.msg._coerce_properties() - self.assertIsInstance(self.msg.properties['timestamp'], - datetime.datetime) + self.assertIsInstance( + self.msg.properties['timestamp'], datetime.datetime + ) class TestPublishing(helpers.TestCase): - - BODY = {'foo': str(uuid.uuid4()), - 'bar': 'baz', - 'qux': 1} + BODY = {'foo': str(uuid.uuid4()), 'bar': 'baz', 'qux': 1} EXCHANGE = 'foo' ROUTING_KEY = 'bar.baz' @mock.patch('rabbitpy.channel.Channel.write_frames') def setUp(self, write_frames): - super(TestPublishing, self).setUp() + super().setUp() self.write_frames = write_frames self.msg = message.Message(self.channel, self.BODY, {'app_id': 'foo'}) self.msg.publish(self.EXCHANGE, self.ROUTING_KEY) def test_publish_invokes_write_frame_with_basic_publish(self): - self.assertIsInstance(self.write_frames.mock_calls[0][1][0][0], - specification.Basic.Publish) + self.assertIsInstance( + self.write_frames.mock_calls[0][1][0][0], + specification.Basic.Publish, + ) def test_publish_with_exchange_object(self): _exchange = exchange.Exchange(self.channel, self.EXCHANGE) with mock.patch('rabbitpy.channel.Channel.write_frames') as wframes: self.msg.publish(_exchange, self.ROUTING_KEY) - self.assertEqual(wframes.mock_calls[0][1][0][0].exchange, - self.EXCHANGE) + self.assertEqual( + wframes.mock_calls[0][1][0][0].exchange, self.EXCHANGE + ) def test_publish_with_exchange_str(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][0].exchange, - self.EXCHANGE) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][0].exchange, self.EXCHANGE + ) def test_publish_routing_key_value(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][0].routing_key, - self.ROUTING_KEY) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][0].routing_key, + self.ROUTING_KEY, + ) def test_publish_mandatory_false_value(self): self.assertFalse(self.write_frames.mock_calls[0][1][0][0].mandatory) @@ -422,34 +424,38 @@ def test_publish_mandatory_true_value(self): self.assertTrue(wframes.mock_calls[0][1][0][0].mandatory) def test_publish_invokes_write_frame_with_content_header(self): - self.assertIsInstance(self.write_frames.mock_calls[0][1][0][1], - header.ContentHeader) + self.assertIsInstance( + self.write_frames.mock_calls[0][1][0][1], header.ContentHeader + ) def test_content_header_frame_body_size(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][1].body_size, - len(self.msg.body)) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][1].body_size, + len(self.msg.body), + ) def test_content_header_frame_properties(self): value = self.write_frames.mock_calls[0][1][0][1].properties for key in self.msg.properties: - self.assertEqual(self.msg.properties[key], - getattr(value, key)) + self.assertEqual(self.msg.properties[key], getattr(value, key)) def test_publish_invokes_write_frame_with_body(self): - self.assertIsInstance(self.write_frames.mock_calls[0][1][0][2], - body.ContentBody) + self.assertIsInstance( + self.write_frames.mock_calls[0][1][0][2], body.ContentBody + ) def test_content_body_value(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][2].value, - bytes(json.dumps(self.BODY).encode('utf-8'))) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][2].value, + bytes(json.dumps(self.BODY).encode('utf-8')), + ) class TestJSONDeserialization(helpers.TestCase): - BODY = b'{"qux": 1, "foo": "d5525b9d", "bar": "baz"}' def setUp(self): - super(TestJSONDeserialization, self).setUp() + super().setUp() self.expectation = json.loads(self.BODY.decode('utf-8')) self.msg = message.Message(self.channel, self.BODY) @@ -458,7 +464,6 @@ def test_json_body(self): class TestPublishingUnicode(helpers.TestCase): - try: BODY = '☢'.decode('utf-8') except AttributeError: @@ -468,25 +473,26 @@ class TestPublishingUnicode(helpers.TestCase): @mock.patch('rabbitpy.channel.Channel.write_frames') def setUp(self, write_frames): - super(TestPublishingUnicode, self).setUp() + super().setUp() self.write_frames = write_frames self.msg = message.Message(self.channel, self.BODY) self.msg.publish(self.EXCHANGE, self.ROUTING_KEY) def test_content_body_value(self): - self.assertEqual(self.write_frames.mock_calls[0][1][0][2].value, - self.BODY.encode('utf-8')) + self.assertEqual( + self.write_frames.mock_calls[0][1][0][2].value, + self.BODY.encode('utf-8'), + ) class TestPublisherConfirms(helpers.TestCase): - BODY = 'confirm-this' EXCHANGE = 'foo' ROUTING_KEY = 'bar.baz' @mock.patch('rabbitpy.channel.Channel.write_frames') def setUp(self, write_frames): - super(TestPublisherConfirms, self).setUp() + super().setUp() self.write_frames = write_frames self.channel._publisher_confirms = True self.channel.wait_for_confirmation = self._confirm_wait = mock.Mock() @@ -502,5 +508,9 @@ def test_confirm_nack_response_returns_false(self): def test_confirm_other_raises(self): self._confirm_wait.return_value = specification.Basic.Consume() - self.assertRaises(exceptions.UnexpectedResponseError, - self.msg.publish, self.EXCHANGE, self.ROUTING_KEY) + self.assertRaises( + exceptions.UnexpectedResponseError, + self.msg.publish, + self.EXCHANGE, + self.ROUTING_KEY, + ) diff --git a/tests/test_queue.py b/tests/test_queue.py index 3152141..bd2d247 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,19 +2,16 @@ Test the rabbitpy.amqp_queue classes """ -import mock -from pamqp import specification -from rabbitpy import amqp_queue -from rabbitpy import channel -from rabbitpy import exceptions -from rabbitpy import utils +from unittest import mock +from pamqp import commands as specification + +from rabbitpy import amqp_queue, exceptions, utils from tests import helpers class QueueInitializationTests(helpers.TestCase): - def test_empty_queue_name(self): queue = amqp_queue.Queue(self.channel) self.assertEqual(queue.name, '') @@ -35,8 +32,9 @@ def test_auto_delete_false(self): self.assertFalse(queue.auto_delete) def test_auto_delete_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, None, None, None, 10) + self.assertRaises( + ValueError, amqp_queue.Queue, self.channel, None, None, None, 10 + ) def test_durable_default(self): queue = amqp_queue.Queue(self.channel) @@ -51,8 +49,9 @@ def test_durable_false(self): self.assertFalse(queue.durable) def test_durable_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, None, 'Foo') + self.assertRaises( + ValueError, amqp_queue.Queue, self.channel, None, 'Foo' + ) def test_exclusive_default(self): queue = amqp_queue.Queue(self.channel) @@ -67,8 +66,9 @@ def test_exclusive_false(self): self.assertFalse(queue.exclusive) def test_exclusive_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, None, None, 'Bar') + self.assertRaises( + ValueError, amqp_queue.Queue, self.channel, None, None, 'Bar' + ) def test_expires_default(self): queue = amqp_queue.Queue(self.channel) @@ -80,14 +80,25 @@ def test_expires_named_value(self): self.assertIsInstance(queue.expires, int) def test_expires_positional_value(self): - queue = amqp_queue.Queue(self.channel, '', True, False, True, - None, None, 10) + queue = amqp_queue.Queue( + self.channel, '', True, False, True, None, None, 10 + ) self.assertEqual(queue.expires, 10) self.assertIsInstance(queue.expires, int) def test_expires_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, '', True, False, True, None, None, 'Foo') + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + None, + 'Foo', + ) def test_max_length_default(self): queue = amqp_queue.Queue(self.channel) @@ -104,8 +115,16 @@ def test_max_length_positional_value(self): self.assertIsInstance(queue.max_length, int) def test_max_length_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, '', True, False, True, 'Foo') + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + 'Foo', + ) def test_message_ttl_default(self): queue = amqp_queue.Queue(self.channel) @@ -122,8 +141,17 @@ def test_message_ttl_positional_value(self): self.assertIsInstance(queue.message_ttl, int) def test_message_ttl_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, - self.channel, '', True, False, True, None, 'Foo') + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + 'Foo', + ) def test_dlx_default(self): queue = amqp_queue.Queue(self.channel) @@ -134,7 +162,9 @@ def test_dlx_value(self): self.assertEqual(queue.dead_letter_exchange, 'dlx-name') def test_dlx_bytes(self): - queue = amqp_queue.Queue(self.channel, dead_letter_exchange=b'dlx-name') + queue = amqp_queue.Queue( + self.channel, dead_letter_exchange=b'dlx-name' + ) self.assertIsInstance(queue.dead_letter_exchange, bytes) def test_dlx_str(self): @@ -142,112 +172,157 @@ def test_dlx_str(self): self.assertIsInstance(queue.dead_letter_exchange, str) @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') - def test_dlx_unicode(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_exchange=unicode('dlx-name')) - self.assertIsInstance(queue.dead_letter_exchange, unicode) + def test_dlx_py2_unicode(self): + queue = amqp_queue.Queue(self.channel, dead_letter_exchange='dlx-name') + self.assertIsInstance(queue.dead_letter_exchange, str) def test_message_dlx_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, self.channel, '', True, - False, True, None, None, None, True) + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + None, + None, + True, + ) def test_dlr_default(self): queue = amqp_queue.Queue(self.channel) self.assertIsNone(queue.dead_letter_routing_key) def test_dlr_value(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key='routing-key') + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key='routing-key' + ) self.assertEqual(queue.dead_letter_routing_key, 'routing-key') def test_dlr_bytes(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key=b'routing-key') + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key=b'routing-key' + ) self.assertIsInstance(queue.dead_letter_routing_key, bytes) def test_dlr_str(self): - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key='routing-key') + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key='routing-key' + ) self.assertIsInstance(queue.dead_letter_routing_key, str) @helpers.unittest.skipIf(utils.PYTHON3, 'No unicode in Python 3') - def test_dlr_unicode(self): - routing_key = unicode('routing-key') - queue = amqp_queue.Queue(self.channel, - dead_letter_routing_key=routing_key) - self.assertIsInstance(queue.dead_letter_routing_key, unicode) + def test_dlr_py2_unicode(self): + routing_key = 'routing-key' + queue = amqp_queue.Queue( + self.channel, dead_letter_routing_key=routing_key + ) + self.assertIsInstance(queue.dead_letter_routing_key, str) def test_dlr_validation(self): - self.assertRaises(ValueError, amqp_queue.Queue, self.channel, '', True, - False, True, None, None, None, None, True) + self.assertRaises( + ValueError, + amqp_queue.Queue, + self.channel, + '', + True, + False, + True, + None, + None, + None, + None, + True, + ) @helpers.unittest.skipIf( - utils.PYPY, 'PyPy bails this due to improper __exit__ behavior') + utils.PYPY, 'PyPy bails this due to improper __exit__ behavior' + ) def test_stop_consuming_raises_exception(self): queue = amqp_queue.Queue(self.channel) self.assertRaises(exceptions.NotConsumingError, queue.stop_consuming) class QueueDeclareTests(helpers.TestCase): - def test_default_declare(self): obj = amqp_queue.Queue(self.channel) - expectation = {'arguments': {}, - 'auto_delete': False, - 'durable': False, - 'exclusive': False, - 'nowait': False, - 'passive': False, - 'queue': '', - 'ticket': 0} + expectation = { + 'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': False, + 'queue': '', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(False)), expectation) def test_default_declare_passive(self): obj = amqp_queue.Queue(self.channel) - expectation = {'arguments': {}, - 'auto_delete': False, - 'durable': False, - 'exclusive': False, - 'nowait': False, - 'passive': True, - 'queue': '', - 'ticket': 0} + expectation = { + 'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': True, + 'queue': '', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(True)), expectation) def test_queue_name(self): obj = amqp_queue.Queue(self.channel, 'my-queue') - expectation = {'arguments': {}, - 'auto_delete': False, - 'durable': False, - 'exclusive': False, - 'nowait': False, - 'passive': False, - 'queue': 'my-queue', - 'ticket': 0} + expectation = { + 'arguments': {}, + 'auto_delete': False, + 'durable': False, + 'exclusive': False, + 'nowait': False, + 'passive': False, + 'queue': 'my-queue', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(False)), expectation) def test_non_defaults(self): - obj = amqp_queue.Queue(self.channel, 'my-queue', False, True, True, - 100, 30000, 60000, 'dlx-name', 'dlrk') - expectation = {'arguments': {'x-expires': 60000, - 'x-max-length': 100, - 'x-message-ttl': 30000, - 'x-dead-letter-exchange': 'dlx-name', - 'x-dead-letter-routing-key': 'dlrk'}, - 'auto_delete': True, - 'durable': False, - 'exclusive': True, - 'nowait': False, - 'passive': False, - 'queue': 'my-queue', - 'ticket': 0} + obj = amqp_queue.Queue( + self.channel, + 'my-queue', + False, + True, + True, + 100, + 30000, + 60000, + 'dlx-name', + 'dlrk', + ) + expectation = { + 'arguments': { + 'x-expires': 60000, + 'x-max-length': 100, + 'x-message-ttl': 30000, + 'x-dead-letter-exchange': 'dlx-name', + 'x-dead-letter-routing-key': 'dlrk', + }, + 'auto_delete': True, + 'durable': False, + 'exclusive': True, + 'nowait': False, + 'passive': False, + 'queue': 'my-queue', + 'ticket': 0, + } self.assertDictEqual(dict(obj._declare(False)), expectation) class QueueAssignmentTests(helpers.TestCase): - def setUp(self): - super(QueueAssignmentTests, self).setUp() + super().setUp() self.queue = amqp_queue.Queue(self.channel) def test_auto_delete_assign_true(self): @@ -261,6 +336,7 @@ def test_auto_delete_assign_false(self): def test_auto_delete_assign_raises_type_error(self): def assign_value(): self.queue.auto_delete = 'Hello' + self.assertRaises(ValueError, assign_value) def test_durable_assign_true(self): @@ -274,6 +350,7 @@ def test_durable_assign_false(self): def test_durable_assign_raises_type_error(self): def assign_value(): self.queue.durable = 'Hello' + self.assertRaises(ValueError, assign_value) def test_exclusive_assign_true(self): @@ -287,6 +364,7 @@ def test_exclusive_assign_false(self): def test_exclusive_assign_raises_type_error(self): def assign_value(): self.queue.exclusive = 'Hello' + self.assertRaises(ValueError, assign_value) def test_expires_assign_value(self): @@ -296,6 +374,7 @@ def test_expires_assign_value(self): def test_expires_assign_raises_type_error(self): def assign_value(): self.queue.expires = 'Hello' + self.assertRaises(ValueError, assign_value) def test_max_length_assign_value(self): @@ -305,6 +384,7 @@ def test_max_length_assign_value(self): def test_max_length_assign_raises_type_error(self): def assign_value(): self.queue.max_length = 'Hello' + self.assertRaises(ValueError, assign_value) def test_message_ttl_assign_value(self): @@ -314,6 +394,7 @@ def test_message_ttl_assign_value(self): def test_message_ttl_assign_raises_type_error(self): def assign_value(): self.queue.message_ttl = 'Hello' + self.assertRaises(ValueError, assign_value) def test_dead_letter_exchange_assign_value(self): @@ -323,6 +404,7 @@ def test_dead_letter_exchange_assign_value(self): def test_dead_letter_exchange_assign_raises_type_error(self): def assign_value(): self.queue.dead_letter_exchange = 1234 + self.assertRaises(ValueError, assign_value) def test_dead_letter_routing_key_assign_value(self): @@ -332,6 +414,7 @@ def test_dead_letter_routing_key_assign_value(self): def test_dead_letter_routing_key_assign_raises_type_error(self): def assign_value(): self.queue.dead_letter_routing_key = 1234 + self.assertRaises(ValueError, assign_value) def test_arguments_assign_value(self): @@ -341,6 +424,7 @@ def test_arguments_assign_value(self): def test_arguments_assign_raises_type_error(self): def assign_value(): self.queue.arguments = 1234 + self.assertRaises(ValueError, assign_value) @@ -356,40 +440,51 @@ class WriteFrameTests(helpers.TestCase): DEAD_LETTER_ROUTING_KEY = 'dead' def setUp(self): - super(WriteFrameTests, self).setUp() - self.queue = amqp_queue.Queue(self.channel, self.NAME, self.DURABLE, - self.EXCLUSIVE, self.AUTO_DELETE, - self.MAX_LENGTH, self.MESSAGE_TTL, - self.EXPIRES, self.DEAD_LETTER_EXCHANGE, - self.DEAD_LETTER_ROUTING_KEY) + super().setUp() + self.queue = amqp_queue.Queue( + self.channel, + self.NAME, + self.DURABLE, + self.EXCLUSIVE, + self.AUTO_DELETE, + self.MAX_LENGTH, + self.MESSAGE_TTL, + self.EXPIRES, + self.DEAD_LETTER_EXCHANGE, + self.DEAD_LETTER_ROUTING_KEY, + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_declare_invokes_write_frame_with_queue_declare(self, rpc): self.queue.declare() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Declare + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_ha_declare_invokes_write_frame_with_queue_declare(self, rpc): self.queue.ha_declare() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Declare + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_ha_declare_list_invokes_write_frame_with_queue_declare(self, rpc): self.queue.ha_declare(['foo', 'bar']) - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Declare) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Declare + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_ha_declare_list_sets_proper_attributes(self, rpc): self.queue.ha_declare(['foo', 'bar']) - self.assertListEqual(self.queue.arguments['x-ha-nodes'], - ['foo', 'bar']) + self.assertListEqual( + self.queue.arguments['x-ha-nodes'], ['foo', 'bar'] + ) self.assertEqual(self.queue.arguments['x-ha-policy'], 'nodes') @mock.patch('rabbitpy.amqp_queue.Queue._rpc') - def test_ha_declare_list_invokes_write_frame_with_queue_declare(self, rpc): + def test_ha_declare_clears_ha_nodes(self, rpc): self.queue.arguments['x-ha-nodes'] = ['foo', 'bar'] self.queue.ha_declare() self.assertNotIn('x-ha-nodes', self.queue.arguments) @@ -398,32 +493,36 @@ def test_ha_declare_list_invokes_write_frame_with_queue_declare(self, rpc): @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_bind_invokes_write_frame_with_queue_bind(self, rpc): self.queue.bind('foo', 'bar') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Bind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Bind + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_unbind_invokes_write_frame_with_queue_declare(self, rpc): self.queue.unbind('foo', 'bar') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Unbind) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Unbind + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_unbind_with_obj_invokes_write_frame_with_queue_declare(self, rpc): exchange = mock.Mock() exchange.name = 'foo' self.queue.unbind(exchange, 'bar') - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Unbind) - + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Unbind + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_unbind_invokes_write_frame_with_queue_delete(self, rpc): self.queue.delete() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Delete) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Delete + ) @mock.patch('rabbitpy.amqp_queue.Queue._rpc') def test_purge_invokes_write_frame_with_queue_purge(self, rpc): self.queue.purge() - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Queue.Purge) + self.assertIsInstance( + rpc.mock_calls[0][1][0], specification.Queue.Purge + ) diff --git a/tests/test_tx.py b/tests/test_tx.py index cd7b685..f57a052 100644 --- a/tests/test_tx.py +++ b/tests/test_tx.py @@ -2,16 +2,16 @@ Test the rabbitpy.tx classes """ -import mock -from pamqp import specification -from rabbitpy import exceptions, tx +from unittest import mock + +from pamqp import commands as specification +from rabbitpy import exceptions, tx from tests import helpers class TxTests(helpers.TestCase): - def test_obj_creation_does_not_invoke_select(self): with mock.patch('rabbitpy.tx.Tx.select') as select: transaction = tx.Tx(self.channel) @@ -26,7 +26,7 @@ def test_enter_invokes_select(self): @mock.patch('rabbitpy.tx.Tx._rpc') def test_exit_invokes_commit(self, rpc): rpc.return_value = specification.Tx.SelectOk - with mock.patch('rabbitpy.tx.Tx.select') as select: + with mock.patch('rabbitpy.tx.Tx.select'): with mock.patch('rabbitpy.tx.Tx.commit') as commit: with tx.Tx(self.channel) as transaction: transaction._selected = True @@ -35,7 +35,7 @@ def test_exit_invokes_commit(self, rpc): @mock.patch('rabbitpy.tx.Tx._rpc') def test_exit_on_exception_invokes_commit_with_selected(self, rpc): rpc.return_value = specification.Tx.SelectOk - with mock.patch('rabbitpy.tx.Tx.select') as select: + with mock.patch('rabbitpy.tx.Tx.select'): with mock.patch('rabbitpy.tx.Tx.rollback') as rollback: try: with tx.Tx(self.channel) as transaction: @@ -50,8 +50,7 @@ def test_select_invokes_rpc_with_tx_select(self, rpc): rpc.return_value = specification.Tx.CommitOk with tx.Tx(self.channel): pass - self.assertIsInstance(rpc.mock_calls[0][1][0], - specification.Tx.Select) + self.assertIsInstance(rpc.mock_calls[0][1][0], specification.Tx.Select) @mock.patch('rabbitpy.tx.Tx._rpc') def test_commit_invokes_rpc_with_tx_commit(self, rpc): @@ -60,16 +59,14 @@ def test_commit_invokes_rpc_with_tx_commit(self, rpc): obj.select() rpc.return_value = specification.Tx.CommitOk obj.commit() - self.assertIsInstance(rpc.mock_calls[1][1][0], - specification.Tx.Commit) + self.assertIsInstance(rpc.mock_calls[1][1][0], specification.Tx.Commit) @mock.patch('rabbitpy.tx.Tx._rpc') def test_commit_raises_when_channel_closed(self, rpc): obj = tx.Tx(self.channel) obj.select() rpc.side_effect = exceptions.ChannelClosedException - self.assertRaises(exceptions.NoActiveTransactionError, - obj.commit) + self.assertRaises(exceptions.NoActiveTransactionError, obj.commit) @mock.patch('rabbitpy.tx.Tx._rpc') def test_rollback_invokes_rpc_with_tx_rollback(self, rpc): @@ -78,13 +75,13 @@ def test_rollback_invokes_rpc_with_tx_rollback(self, rpc): obj.select() rpc.return_value = specification.Tx.RollbackOk obj.rollback() - self.assertIsInstance(rpc.mock_calls[1][1][0], - specification.Tx.Rollback) + self.assertIsInstance( + rpc.mock_calls[1][1][0], specification.Tx.Rollback + ) @mock.patch('rabbitpy.tx.Tx._rpc') def test_rollback_raises_when_channel_closed(self, rpc): obj = tx.Tx(self.channel) obj.select() rpc.side_effect = exceptions.ChannelClosedException - self.assertRaises(exceptions.NoActiveTransactionError, - obj.rollback) + self.assertRaises(exceptions.NoActiveTransactionError, obj.rollback) diff --git a/tests/test_url_parser.py b/tests/test_url_parser.py new file mode 100644 index 0000000..1c033ca --- /dev/null +++ b/tests/test_url_parser.py @@ -0,0 +1,29 @@ +import unittest +import uuid + +from rabbitpy import url_parser + + +class URLParsingTestCase(unittest.TestCase): + def test_default_url(self): + args = url_parser.parse() + self.assertEqual(args['host'], 'localhost') + self.assertEqual(args['port'], 5672) + self.assertEqual(args['virtual_host'], '/') + self.assertEqual(args['username'], 'guest') + self.assertEqual(args['password'], 'guest') + self.assertEqual(args['heartbeat'], 60) + self.assertEqual(args['ssl'], False) + self.assertEqual(args['timeout'], 3) + + def test_amqps(self): + pwd = str(uuid.uuid4()) + args = url_parser.parse(f'amqps://guest:{pwd}@localhost:5671/') + self.assertEqual(args['host'], 'localhost') + self.assertEqual(args['port'], 5671) + self.assertEqual(args['virtual_host'], '/') + self.assertEqual(args['username'], 'guest') + self.assertEqual(args['password'], pwd) + self.assertEqual(args['heartbeat'], 60) + self.assertEqual(args['ssl'], True) + self.assertEqual(args['timeout'], 3) diff --git a/tests/utils_tests.py b/tests/utils_tests.py index 6aacc4f..3c13efd 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -2,20 +2,17 @@ Test the rabbitpy utils module """ -try: - import unittest2 as unittest -except ImportError: - import unittest + import sys +import unittest + from rabbitpy import utils # 3 Unicode Compatibility hack -if sys.version_info[0] == 3: - unicode = str +unicode = str class UtilsTestCase(unittest.TestCase): - AMQP = 'amqp://guest:guest@localhost:5672/%2F?heartbeat_interval=1' AMQPS = 'amqps://guest:guest@localhost:5672/%2F?heartbeat_interval=1' @@ -47,8 +44,9 @@ def test_urlparse_fragment(self): self.assertEqual(utils.urlparse(self.AMQPS).fragment, self.FRAGMENT) def test_parse_qs(self): - self.assertDictEqual(utils.parse_qs(self.QUERY), - {'heartbeat_interval': ['1']}) + self.assertDictEqual( + utils.parse_qs(self.QUERY), {'heartbeat_interval': ['1']} + ) def test_is_string_str(self): self.assertTrue(utils.is_string('Foo')) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 6c5f3db..0000000 --- a/tox.ini +++ /dev/null @@ -1,9 +0,0 @@ -[tox] -indexserver = - default = https://pypi.python.org/simple -envlist = py26,py27,py32,py33,py34 -deps = -rrequirements.txt -commands=nosetests - -[tox:py26] -deps = unittest2 \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..357478f --- /dev/null +++ b/uv.lock @@ -0,0 +1,1120 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, +] + +[[package]] +name = "backrefs" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075 }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874 }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787 }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747 }, + { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602 }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077 }, +] + +[[package]] +name = "basedpyright" +version = "1.38.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/b4/26cb812eaf8ab56909c792c005fe1690706aef6f21d61107639e46e9c54c/basedpyright-1.38.4.tar.gz", hash = "sha256:8e7d4f37ffb6106621e06b9355025009cdf5b48f71c592432dd2dd304bf55e70", size = 25354730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0b/3f95fd47def42479e61077523d3752086d5c12009192a7f1c9fd5507e687/basedpyright-1.38.4-py3-none-any.whl", hash = "sha256:90aa067cf3e8a3c17ad5836a72b9e1f046bc72a4ad57d928473d9368c9cd07a2", size = 12352258 }, +] + +[[package]] +name = "build" +version = "1.4.2" +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/6c/1d/ab15c8ac57f4ee8778d7633bc6685f808ab414437b8644f555389cdc875e/build-1.4.2.tar.gz", hash = "sha256:35b14e1ee329c186d3f08466003521ed7685ec15ecffc07e68d706090bf161d1", size = 83433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/57/3b7d4dd193ade4641c865bc2b93aeeb71162e81fc348b8dad020215601ed/build-1.4.2-py3-none-any.whl", hash = "sha256:7a4d8651ea877cb2a89458b1b198f2e69f536c95e89129dbf5d448045d60db88", size = 24643 }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582 }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240 }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363 }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994 }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697 }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673 }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120 }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911 }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516 }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795 }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920 }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951 }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703 }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857 }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751 }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154 }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191 }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674 }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259 }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276 }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161 }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452 }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272 }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622 }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056 }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751 }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563 }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265 }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229 }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277 }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817 }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823 }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527 }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563 }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587 }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724 }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956 }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923 }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366 }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752 }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296 }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956 }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652 }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940 }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101 }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109 }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458 }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277 }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758 }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299 }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811 }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706 }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706 }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497 }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511 }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133 }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035 }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321 }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973 }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610 }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962 }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595 }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828 }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138 }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679 }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475 }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230 }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045 }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658 }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769 }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328 }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302 }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127 }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840 }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890 }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379 }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043 }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523 }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381 }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880 }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303 }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218 }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326 }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267 }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430 }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017 }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080 }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843 }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802 }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707 }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880 }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816 }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483 }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554 }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908 }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419 }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159 }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270 }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538 }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821 }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191 }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337 }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404 }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903 }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780 }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093 }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900 }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515 }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576 }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942 }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935 }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541 }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780 }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912 }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165 }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908 }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873 }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030 }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694 }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469 }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112 }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923 }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540 }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262 }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617 }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912 }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987 }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416 }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558 }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163 }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981 }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604 }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321 }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502 }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688 }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788 }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851 }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104 }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621 }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953 }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992 }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503 }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852 }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161 }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021 }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858 }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823 }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099 }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638 }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295 }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360 }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174 }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739 }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351 }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612 }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985 }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107 }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513 }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650 }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089 }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982 }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579 }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316 }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427 }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745 }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146 }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254 }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276 }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759 }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357 }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315 }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021 }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500 }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622 }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304 }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493 }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129 }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113 }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269 }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673 }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597 }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733 }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273 }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529 }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669 }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279 }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288 }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809 }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075 }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486 }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219 }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750 }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624 }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969 }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000 }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495 }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081 }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309 }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804 }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907 }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217 }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622 }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987 }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132 }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195 }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946 }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689 }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875 }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058 }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313 }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994 }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770 }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409 }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473 }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866 }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248 }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629 }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615 }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328 }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722 }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755 }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523 }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779 }, +] + +[[package]] +name = "mypy" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972 }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007 }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752 }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265 }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476 }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226 }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091 }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525 }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469 }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953 }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363 }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005 }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616 }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091 }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344 }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400 }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384 }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378 }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170 }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526 }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456 }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331 }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047 }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585 }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075 }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141 }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925 }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089 }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710 }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013 }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240 }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565 }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874 }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380 }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174 }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e5755ad739daafce2e152ab609293d65e6c663b399e28a4bbcd0f4af1f45/nodejs_wheel_binaries-24.14.1.tar.gz", hash = "sha256:d00ae0c86d7e1bfa798e8f8ad282db751af157cdcaa1208a1b9a2cf2a85ac821", size = 8056 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/b7/9765d9a5d3b95475829ef5965d4a4f6f4badb034ee4e18c2d5f8b9b65d6f/nodejs_wheel_binaries-24.14.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9e856ba0f2d3d2659869e6e0f4cae6874faeeeca7f879131a88451356373ac4", size = 54945603 }, + { url = "https://files.pythonhosted.org/packages/6f/15/bc2fa51ee31ce597b2af1905081e5a5add07fe0cf619bfa531d7df2f1f1b/nodejs_wheel_binaries-24.14.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:634f57829ebfdfe95d096f32a50c5cdd3a6c72a94dcf2b92a8bef9868cccb13e", size = 55119951 }, + { url = "https://files.pythonhosted.org/packages/a6/dd/92ff0831262af4bbb5473d4e7964fd27afb0901a2690a6ff7bc3d220d97f/nodejs_wheel_binaries-24.14.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:404b563467129e6a0ea7006a38b3d8af0ebfbc340b31a6a0af2c59ea3af90b7c", size = 59487620 }, + { url = "https://files.pythonhosted.org/packages/45/36/bbbee3adf6afd00944e5a86ebd64987dea90bd347090155a4989dc3e8594/nodejs_wheel_binaries-24.14.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7863c62f8a3946b727831f71375a9ae00205b3258478476034b49c3a1d57ac12", size = 59986044 }, + { url = "https://files.pythonhosted.org/packages/05/16/119e4168bf7ed17ad7961d122701c75ac86135fa243a958b960a3f1b7055/nodejs_wheel_binaries-24.14.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a3f64daa1235fa6a83c778ded98d5fe4e74979ca54aa2ffb807f0805c57c3abe", size = 61489823 }, + { url = "https://files.pythonhosted.org/packages/8c/a6/d581996827b9d1133094dc347f1c4e3d2a70557973ce7a427a03337b1427/nodejs_wheel_binaries-24.14.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:810a48ce096925ead0690f7d143e48fb902ebfc9212097e8f6cb3ac6cbe8f314", size = 62069740 }, + { url = "https://files.pythonhosted.org/packages/27/da/396d1a48cbf3d5899461bda12fa97ae4010d7e4013e7f28230cb04af5818/nodejs_wheel_binaries-24.14.1-py2.py3-none-win_amd64.whl", hash = "sha256:7a087b6a727fb9242d1cc83c8b121711bd0e9686408d27de48b34b23dfb26ac5", size = 41400067 }, + { url = "https://files.pythonhosted.org/packages/13/b7/adb21cf549934579e98934531e7f9b038d583fc9c2dd4b82ea01cc31bdd2/nodejs_wheel_binaries-24.14.1-py2.py3-none-win_arm64.whl", hash = "sha256:978fdfe76624c48111ab99ed0f99f9d4c1c682e420b0212ac9e1daee52f20283", size = 39096873 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + +[[package]] +name = "pamqp" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/92/3db569ab046955553d6041ea5ad1fa78aaa01fc70bc4e6e7e15c072f050e/pamqp-4.0.0.tar.gz", hash = "sha256:0acc96ae642a534c61f12942ee23167ed52624209baa92c9848d0970f4604682", size = 130078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/23/64f267632476a5c512df38e6f0d6eb215ebc4ff59edd17d152b7e3bee1ee/pamqp-4.0.0-py3-none-any.whl", hash = "sha256:ec5757355b4a54f4cf9c172e8dde89ed14f420970bc64a0c9019920c4945c71b", size = 32443 }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216 }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-discovery" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, +] + +[[package]] +name = "rabbitpy" +version = "3.0.0" +source = { editable = "." } +dependencies = [ + { name = "pamqp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "build" }, + { name = "coverage", extra = ["toml"] }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "python-dotenv" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] + +[package.metadata] +requires-dist = [{ name = "pamqp", specifier = ">=4.0,<5.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright" }, + { name = "build" }, + { name = "coverage", extras = ["toml"] }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "python-dotenv" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.5,<2" }, + { name = "mkdocs-material", specifier = ">=9.5,<10" }, + { name = "mkdocstrings", extras = ["python"] }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394 }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693 }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044 }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135 }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041 }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987 }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057 }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613 }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557 }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440 }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963 }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484 }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426 }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125 }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959 }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893 }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +]