From 4a1e2e93719d850ac7053f8b2f3666ac2ce7fdb5 Mon Sep 17 00:00:00 2001 From: Rajiv Sarvepalli Date: Sun, 10 Oct 2021 16:26:39 -0400 Subject: [PATCH] Added mock scalar method (#155) * Added mock scalar method * Added docs and module for test data * Updated dependencies * Added security policy --- .flake8 | 3 +- .pre-commit-config.yaml | 5 +- CODE_OF_CONDUCT.md | 164 ++++++++++++++++++++++++++++ CODE_OF_CONDUCT.rst | 107 ------------------- SECURITY.md | 17 +++ docs/conf.py | 2 +- docs/user_guide/index.rst | 48 +++++++++ noxfile.py | 8 +- poetry.lock | 207 ++++++++++++++++-------------------- pyproject.toml | 8 +- requirements-dev.txt | 2 +- src/mock_alchemy/mocking.py | 10 +- src/mock_alchemy/utils.py | 12 +++ tests/__init__.py | 1 + tests/common.py | 93 ++++++++++++++++ tests/test_mocking.py | 196 +++++++++++----------------------- tests/test_utils.py | 27 ++--- 17 files changed, 531 insertions(+), 379 deletions(-) create mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CODE_OF_CONDUCT.rst create mode 100644 SECURITY.md create mode 100644 tests/__init__.py create mode 100644 tests/common.py diff --git a/.flake8 b/.flake8 index 293c1ee..7d90948 100644 --- a/.flake8 +++ b/.flake8 @@ -6,4 +6,5 @@ max-complexity = 10 application-import-names = mock_alchemy,tests import-order-style = pep8 docstring-convention = google -per-file-ignores = tests/*:S101 +per-file-ignores = + tests/*:S101,I202 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9af7992..adef0a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,12 +33,11 @@ repos: language: system types: [python] require_serial: true - - id: reorder-python-imports + - id: isort name: Reorder python imports - entry: reorder-python-imports + entry: isort language: system types: [python] - args: [--application-directories=src] - id: trailing-whitespace name: Trim Trailing Whitespace entry: trailing-whitespace-fixer diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c3a0cf5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,164 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in +our community a harassment-free experience for everyone, regardless of +age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, +welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our + mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or + political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in + a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our +standards of acceptable behavior and will take appropriate and fair +corrective action in response to any behavior that they deem +inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other +contributions that are not aligned to this Code of Conduct, and will +communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also +applies when an individual is officially representing the community in +public spaces. Examples of representing our community include using an +official e-mail address, posting via an official social media account, +or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may +be reported to the community leaders responsible for enforcement at +[@rajivsarvepalli](https://github.com/rajivsarvepalli). All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security +of the reporter of any incident. + +# Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our +standards of acceptable behavior and will take appropriate and fair +corrective action in response to any behavior that they deem +inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other +contributions that are not aligned to this Code of Conduct, and will +communicate reasons for moderation decisions when appropriate. + +# Scope + +This Code of Conduct applies within all community spaces, and also +applies when an individual is officially representing the community in +public spaces. Examples of representing our community include using an +official e-mail address, posting via an official social media account, +or acting as an appointed representative at an online or offline event. + +# Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may +be reported to the community leaders responsible for enforcement at +[@rajivsarvepalli](https://github.com/rajivsarvepalli). All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security +of the reporter of any incident. + +# Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in +determining the consequences for any action they deem in violation of +this Code of Conduct: + +## 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior +deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, +providing clarity around the nature of the violation and an explanation +of why the behavior was inappropriate. A public apology may be +requested. + +## 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, for a specified period of +time. This includes avoiding interactions in community spaces as well as +external channels like social media. Violating these terms may lead to a +temporary or permanent ban. + +## 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, +including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No +public or private interaction with the people involved, including +unsolicited interaction with those enforcing the Code of Conduct, is +allowed during this period. Violating these terms may lead to a +permanent ban. + +# 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of +individuals. + +**Consequence**: A permanent ban from any sort of public interaction +within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][], +version 2.0, available at +. + +Community Impact Guidelines were inspired by [Mozilla’s code of conduct +enforcement ladder][]. + +For answers to common questions about this code of conduct, see the FAQ +at . Translations are +available at . + +[contributor covenant]: https://www.contributor-covenant.org +[mozilla’s code of conduct enforcement ladder]: https://github.com/mozilla/diversity diff --git a/CODE_OF_CONDUCT.rst b/CODE_OF_CONDUCT.rst deleted file mode 100644 index 19bfbb0..0000000 --- a/CODE_OF_CONDUCT.rst +++ /dev/null @@ -1,107 +0,0 @@ -.. _codeofconduct: - -Contributor Covenant Code of Conduct -==================================== - -Our Pledge ----------- - -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. - - -Our Standards -------------- - -Examples of behavior that contributes to a positive environment for our community include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -Enforcement Responsibilities ----------------------------- - -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. - - -Scope ------ - -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. - - -Enforcement ------------ - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at rajiv@sarvepalli.net. All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the reporter of any incident. - - -Enforcement Guidelines ----------------------- - -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: - - -1. Correction -~~~~~~~~~~~~~ - -**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. - - -2. Warning -~~~~~~~~~~ - -**Community Impact**: A violation through a single incident or series of actions. - -**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. - - -3. Temporary Ban -~~~~~~~~~~~~~~~~ - -**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. - - -4. Permanent Ban -~~~~~~~~~~~~~~~~ - -**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the community. - - -Attribution ------------ - -This Code of Conduct is adapted from the `Contributor Covenant `__, version 2.0, -available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by `Mozilla’s code of conduct enforcement ladder `__. - -.. _homepage: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e74baab --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.2.x | :white_check_mark: | +| 0.1.1 | :white_check_mark: | +| 0.1.x | :x: | + +## Reporting a Vulnerability + +This library is highly unlikely to contain any vulnerabilities outside of dependency vulnerabilities +(for which [@dependabot](https://github.com/rajivsarvepalli/mock-alchemy/security/dependabot) is scanning for). + +However, should you discover any vulnerabilities, please report them +by [creating an issue](https://github.com/rajivsarvepalli/mock-alchemy/issues). diff --git a/docs/conf.py b/docs/conf.py index 90e0f70..8c1b16e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,7 @@ project = "mock-alchemy" author = "Rajiv Sarvepalli" copyright = f"{datetime.now().year}, {author}" -version = "0.2.3" +version = "0.2.4" extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index dacaa01..aaf242d 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -122,6 +122,51 @@ session is unable to actually apply any filters so it returns everything:: >>> session.query(Model).filter(Model.foo == 'bar').all() [Model(foo='bar'), Model(foo='baz')] +Scalar in Sessions +++++++++++++++++++ +You can now mock `scalar() `__. +The below example shows querying on a specific attribute and returning it using ``scalar()``. +To do this you must create another object to represent the returned attribute (in this case, it is called +``Attribute``). Scalar is then used to get the first column of the first row as shown. + +.. code-block:: python + + from sqlalchemy import Column, String + from sqlalchemy.ext.declarative import declarative_base + from mock_alchemy.mocking import UnifiedAlchemyMagicMock + from unittest import mock + + Base = declarative_base() + + class SomeTable(Base): + """SQLAlchemy object representing some table.""" + __tablename__ = "some_table" + pkey = Column(String, primary_key=True) + col1 = Column(String(50)) + + class SingleColumn(Base): + """SQLAlchemy object for mocking a query for a specific column.""" + __tablename__ = "column" + col1 = Column(String(50), primary_key=True) + + mock_session = UnifiedAlchemyMagicMock( + data=[ + ( + [ + mock.call.query(SomeTable.col1), + mock.call.filter(SomeTable.pkey == 3), + ], + [SingleColumn(col1="test")], + ) + ] + ) + found_col1 = ( + mock_session.query(SomeTable.col1) + .filter(SomeTable.pkey == 3) + .scalar() + ) + assert found_col1 == "test" + Deleting in Sessions ++++++++++++++++++++ @@ -445,6 +490,9 @@ for specific objects being present and ensure their values are correct and still anyalsis3 = session.query(CombinedAnalysis).get({"pk1": 3}) assert anyalsis3 == expected_anyalsis3 + + + Abstract Classes ^^^^^^^^^^^^^^^^ diff --git a/noxfile.py b/noxfile.py index ac166cd..5a18250 100644 --- a/noxfile.py +++ b/noxfile.py @@ -10,6 +10,7 @@ package = "mock_alchemy" python_versions = ["3.9", "3.8", "3.7"] +sqlalchemy_versions = ["1.3.22", "1.4.0"] nox.options.sessions = ( "pre-commit", "safety", @@ -85,7 +86,7 @@ def precommit(session: Session) -> None: "flake8-rst-docstrings", "flake8-annotations", "flake8-import-order", - "reorder-python-imports", + "isort", "pep8-naming", "pre-commit", "pre-commit-hooks", @@ -115,9 +116,12 @@ def mypy(session: Session) -> None: @session(python=python_versions) -def tests(session: Session) -> None: +@nox.parametrize("sqlalchemy", sqlalchemy_versions) +def tests(session: Session, sqlalchemy: str) -> None: """Run the test suite.""" session.install(".") + # TODO: Revisit how to parameterize sessions for different sqlalchemy versions + session.run("pip", "install", f"sqlalchemy~={sqlalchemy}") session.install("coverage[toml]", "pytest", "pygments") try: session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs) diff --git a/poetry.lock b/poetry.lock index 8df9581..d761a12 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,17 +6,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "aspy.refactor-imports" -version = "2.2.0" -description = "Utilities for refactoring imports in python-like syntax." -category = "dev" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -cached-property = "*" - [[package]] name = "atomicwrites" version = "1.4.0" @@ -123,17 +112,9 @@ jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] python2 = ["typed-ast (>=1.4.2)"] uvloop = ["uvloop (>=0.15.2)"] -[[package]] -name = "cached-property" -version = "1.5.2" -description = "A decorator for caching properties in classes." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "certifi" -version = "2021.5.30" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -160,7 +141,7 @@ unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "8.0.1" +version = "8.0.3" description = "Composable command line interface toolkit" category = "dev" optional = false @@ -246,15 +227,15 @@ pipenv = ["pipenv"] [[package]] name = "filelock" -version = "3.2.0" +version = "3.3.0" description = "A platform independent file lock." category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] -testing = ["coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] [[package]] name = "flake8" @@ -394,7 +375,7 @@ docs = ["sphinx"] [[package]] name = "identify" -version = "2.2.15" +version = "2.3.0" description = "File identification library for Python" category = "dev" optional = false @@ -444,9 +425,23 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "isort" +version = "5.9.3" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + [[package]] name = "jinja2" -version = "3.0.1" +version = "3.0.2" description = "A very fast and expressive template engine." category = "dev" optional = false @@ -618,7 +613,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydata-sphinx-theme" -version = "0.7.0" +version = "0.7.1" description = "Bootstrap-based Sphinx theme from the PyData community" category = "dev" optional = false @@ -711,7 +706,7 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale [[package]] name = "pytz" -version = "2021.1" +version = "2021.3" description = "World timezone definitions, modern and historical" category = "dev" optional = false @@ -727,23 +722,12 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "regex" -version = "2021.9.30" +version = "2021.10.8" description = "Alternative regular expression module, to replace re." category = "dev" optional = false python-versions = "*" -[[package]] -name = "reorder-python-imports" -version = "2.6.0" -description = "Tool for reordering python imports" -category = "dev" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -"aspy.refactor-imports" = ">=2.1.0" - [[package]] name = "requests" version = "2.26.0" @@ -1098,7 +1082,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", [[package]] name = "xdoctest" -version = "0.15.9" +version = "0.15.10" description = "A rewrite of the builtin doctest module" category = "dev" optional = false @@ -1108,11 +1092,11 @@ python-versions = "*" six = "*" [package.extras] -all = ["pygments", "cmake", "codecov", "ninja", "pybind11", "scikit-build", "six", "colorama", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "ipython", "ipykernel", "jupyter-client", "nbconvert", "nbformat", "pytest", "pytest-cov"] +all = ["six", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] colors = ["pygments", "colorama"] -jupyter = ["ipython", "ipykernel", "jupyter-client", "nbconvert", "nbformat"] -optional = ["pygments", "colorama", "ipython", "ipykernel", "jupyter-client", "nbconvert", "nbformat"] -tests = ["cmake", "codecov", "ninja", "pybind11", "scikit-build", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "ipython", "ipykernel", "jupyter-client", "nbconvert", "nbformat", "pytest", "pytest-cov"] +jupyter = ["nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +optional = ["pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] [[package]] name = "zipp" @@ -1129,17 +1113,13 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "2fee371c252fe5b884e2604e155b137ddf4145007a6a96d26b611b835674c433" +content-hash = "0b820ab8f120796c803e81c3b71c5eb763dbe9aed057df3ceb6882c8ef1a9809" [metadata.files] alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] -"aspy.refactor-imports" = [ - {file = "aspy.refactor_imports-2.2.0-py2.py3-none-any.whl", hash = "sha256:7a18039d2e8be6b02b4791ce98891deb46b459b575c52ed35ab818c4eaa0c098"}, - {file = "aspy.refactor_imports-2.2.0.tar.gz", hash = "sha256:78ca24122963fd258ebfc4a8dc708d23a18040ee39dca8767675821e84e9ea0a"}, -] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -1168,13 +1148,9 @@ black = [ {file = "black-21.9b0-py3-none-any.whl", hash = "sha256:380f1b5da05e5a1429225676655dddb96f5ae8c75bdf91e53d798871b902a115"}, {file = "black-21.9b0.tar.gz", hash = "sha256:7de4cfc7eb6b710de325712d40125689101d21d25283eed7e9998722cf10eb91"}, ] -cached-property = [ - {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, - {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, -] certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, @@ -1185,8 +1161,8 @@ charset-normalizer = [ {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"}, ] click = [ - {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, - {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, ] codecov = [ {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, @@ -1268,8 +1244,8 @@ dparse = [ {file = "dparse-0.5.1.tar.gz", hash = "sha256:a1b5f169102e1c894f9a7d5ccf6f9402a836a5d24be80a986c7ce9eaed78f367"}, ] filelock = [ - {file = "filelock-3.2.0-py2.py3-none-any.whl", hash = "sha256:61a99e9b12b47b685d1389f4cf969c1eba0efd2348a8471f86e01e8c622267af"}, - {file = "filelock-3.2.0.tar.gz", hash = "sha256:85ecb30757aa19d06bfcdad29cc332b9a3e4851bf59976aea1e8dadcbd9ef883"}, + {file = "filelock-3.3.0-py3-none-any.whl", hash = "sha256:bbc6a0382fe8ec4744ecdf6683a2e07f65eb10ff1aff53fc02a202565446cde0"}, + {file = "filelock-3.3.0.tar.gz", hash = "sha256:8c7eab13dc442dc249e95158bcc12dec724465919bdc9831fdbf0660f03d1785"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, @@ -1363,8 +1339,8 @@ greenlet = [ {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, ] identify = [ - {file = "identify-2.2.15-py2.py3-none-any.whl", hash = "sha256:de83a84d774921669774a2000bf87ebba46b4d1c04775f4a5d37deff0cf39f73"}, - {file = "identify-2.2.15.tar.gz", hash = "sha256:528a88021749035d5a39fe2ba67c0642b8341aaf71889da0e1ed669a429b87f0"}, + {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"}, + {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, @@ -1382,9 +1358,13 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] +isort = [ + {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, + {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, +] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, + {file = "Jinja2-3.0.2-py3-none-any.whl", hash = "sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c"}, + {file = "Jinja2-3.0.2.tar.gz", hash = "sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45"}, ] markupsafe = [ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, @@ -1499,8 +1479,8 @@ pycodestyle = [ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pydata-sphinx-theme = [ - {file = "pydata-sphinx-theme-0.7.0.tar.gz", hash = "sha256:e549c907b1e0bb08b152db20daf577522d74a650e157b3dad8385e5db4c6e182"}, - {file = "pydata_sphinx_theme-0.7.0-py3-none-any.whl", hash = "sha256:60af9000ce6b512e20102b8b43e8f0a3bbc700579acc078afe35eb362aa10949"}, + {file = "pydata-sphinx-theme-0.7.1.tar.gz", hash = "sha256:bda31425085f076932f0a85cb6156f63ff82673d75100614bebb5e5f28798166"}, + {file = "pydata_sphinx_theme-0.7.1-py3-none-any.whl", hash = "sha256:354919a7b13e83dfc8df06e92d11ce7087875dcd6d34e4eeed997074e18ed131"}, ] pydocstyle = [ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, @@ -1527,8 +1507,8 @@ pytest-cov = [ {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] pytz = [ - {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, - {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, ] pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, @@ -1554,51 +1534,47 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] regex = [ - {file = "regex-2021.9.30-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66696c8336a1b5d1182464f3af3427cc760118f26d0b09a2ddc16a976a4d2637"}, - {file = "regex-2021.9.30-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d87459ad3ab40cd8493774f8a454b2e490d8e729e7e402a0625867a983e4e02"}, - {file = "regex-2021.9.30-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cf6a1e023caf5e9a982f5377414e1aeac55198831b852835732cfd0a0ca5ff"}, - {file = "regex-2021.9.30-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:255791523f80ea8e48e79af7120b4697ef3b74f6886995dcdb08c41f8e516be0"}, - {file = "regex-2021.9.30-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e502f8d4e5ef714bcc2c94d499684890c94239526d61fdf1096547db91ca6aa6"}, - {file = "regex-2021.9.30-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4907fb0f9b9309a5bded72343e675a252c2589a41871874feace9a05a540241e"}, - {file = "regex-2021.9.30-cp310-cp310-win32.whl", hash = "sha256:3be40f720af170a6b20ddd2ad7904c58b13d2b56f6734ee5d09bbdeed2fa4816"}, - {file = "regex-2021.9.30-cp310-cp310-win_amd64.whl", hash = "sha256:c2b180ed30856dfa70cfe927b0fd38e6b68198a03039abdbeb1f2029758d87e7"}, - {file = "regex-2021.9.30-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6f2d2f93001801296fe3ca86515eb04915472b5380d4d8752f09f25f0b9b0ed"}, - {file = "regex-2021.9.30-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fa7ba9ab2eba7284e0d7d94f61df7af86015b0398e123331362270d71fab0b9"}, - {file = "regex-2021.9.30-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28040e89a04b60d579c69095c509a4f6a1a5379cd865258e3a186b7105de72c6"}, - {file = "regex-2021.9.30-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f588209d3e4797882cd238195c175290dbc501973b10a581086b5c6bcd095ffb"}, - {file = "regex-2021.9.30-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42952d325439ef223e4e9db7ee6d9087b5c68c5c15b1f9de68e990837682fc7b"}, - {file = "regex-2021.9.30-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cae4099031d80703954c39680323dabd87a69b21262303160776aa0e55970ca0"}, - {file = "regex-2021.9.30-cp36-cp36m-win32.whl", hash = "sha256:0de8ad66b08c3e673b61981b9e3626f8784d5564f8c3928e2ad408c0eb5ac38c"}, - {file = "regex-2021.9.30-cp36-cp36m-win_amd64.whl", hash = "sha256:b345ecde37c86dd7084c62954468a4a655fd2d24fd9b237949dd07a4d0dd6f4c"}, - {file = "regex-2021.9.30-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6f08187136f11e430638c2c66e1db091105d7c2e9902489f0dbc69b44c222b4"}, - {file = "regex-2021.9.30-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b55442650f541d195a535ccec33078c78a9521973fb960923da7515e9ed78fa6"}, - {file = "regex-2021.9.30-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87e9c489aa98f50f367fb26cc9c8908d668e9228d327644d7aa568d47e456f47"}, - {file = "regex-2021.9.30-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2cb7d4909ed16ed35729d38af585673f1f0833e73dfdf0c18e5be0061107b99"}, - {file = "regex-2021.9.30-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0861e7f6325e821d5c40514c551fd538b292f8cc3960086e73491b9c5d8291d"}, - {file = "regex-2021.9.30-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:81fdc90f999b2147fc62e303440c424c47e5573a9b615ed5d43a5b832efcca9e"}, - {file = "regex-2021.9.30-cp37-cp37m-win32.whl", hash = "sha256:8c1ad61fa024195136a6b7b89538030bd00df15f90ac177ca278df9b2386c96f"}, - {file = "regex-2021.9.30-cp37-cp37m-win_amd64.whl", hash = "sha256:e3770781353a4886b68ef10cec31c1f61e8e3a0be5f213c2bb15a86efd999bc4"}, - {file = "regex-2021.9.30-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9c065d95a514a06b92a5026766d72ac91bfabf581adb5b29bc5c91d4b3ee9b83"}, - {file = "regex-2021.9.30-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9925985be05d54b3d25fd6c1ea8e50ff1f7c2744c75bdc4d3b45c790afa2bcb3"}, - {file = "regex-2021.9.30-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470f2c882f2672d8eeda8ab27992aec277c067d280b52541357e1acd7e606dae"}, - {file = "regex-2021.9.30-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ad0517df22a97f1da20d8f1c8cb71a5d1997fa383326b81f9cf22c9dadfbdf34"}, - {file = "regex-2021.9.30-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e30838df7bfd20db6466fd309d9b580d32855f8e2c2e6d74cf9da27dcd9b63"}, - {file = "regex-2021.9.30-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5b34d2335d6aedec7dcadd3f8283b9682fadad8b9b008da8788d2fce76125ebe"}, - {file = "regex-2021.9.30-cp38-cp38-win32.whl", hash = "sha256:e07049cece3462c626d650e8bf42ddbca3abf4aa08155002c28cb6d9a5a281e2"}, - {file = "regex-2021.9.30-cp38-cp38-win_amd64.whl", hash = "sha256:37868075eda024470bd0feab872c692ac4ee29db1e14baec103257bf6cc64346"}, - {file = "regex-2021.9.30-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d331f238a7accfbbe1c4cd1ba610d4c087b206353539331e32a8f05345c74aec"}, - {file = "regex-2021.9.30-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6348a7ab2a502cbdd0b7fd0496d614007489adb7361956b38044d1d588e66e04"}, - {file = "regex-2021.9.30-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce7b1cca6c23f19bee8dc40228d9c314d86d1e51996b86f924aca302fc8f8bf9"}, - {file = "regex-2021.9.30-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1f1125bc5172ab3a049bc6f4b9c0aae95a2a2001a77e6d6e4239fa3653e202b5"}, - {file = "regex-2021.9.30-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:638e98d069b14113e8afba6a54d1ca123f712c0d105e67c1f9211b2a825ef926"}, - {file = "regex-2021.9.30-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a0b0db6b49da7fa37ca8eddf9f40a8dbc599bad43e64f452284f37b6c34d91c"}, - {file = "regex-2021.9.30-cp39-cp39-win32.whl", hash = "sha256:9910869c472e5a6728680ca357b5846546cbbd2ab3ad5bef986ef0bc438d0aa6"}, - {file = "regex-2021.9.30-cp39-cp39-win_amd64.whl", hash = "sha256:3b71213ec3bad9a5a02e049f2ec86b3d7c3e350129ae0f4e2f99c12b5da919ed"}, - {file = "regex-2021.9.30.tar.gz", hash = "sha256:81e125d9ba54c34579e4539a967e976a3c56150796674aec318b1b2f49251be7"}, -] -reorder-python-imports = [ - {file = "reorder_python_imports-2.6.0-py2.py3-none-any.whl", hash = "sha256:54a3afd594a3959b10f7eb8b54ef453eb2b5176eb7b01c111cb1893ff9a2c685"}, - {file = "reorder_python_imports-2.6.0.tar.gz", hash = "sha256:f4dc03142bdb57625e64299aea80e9055ce0f8b719f8f19c217a487c9fa9379e"}, + {file = "regex-2021.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:981c786293a3115bc14c103086ae54e5ee50ca57f4c02ce7cf1b60318d1e8072"}, + {file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51feefd58ac38eb91a21921b047da8644155e5678e9066af7bcb30ee0dca7361"}, + {file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8de658d7db5987b11097445f2b1f134400e2232cb40e614e5f7b6f5428710e"}, + {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1ce02f420a7ec3b2480fe6746d756530f69769292eca363218c2291d0b116a01"}, + {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39079ebf54156be6e6902f5c70c078f453350616cfe7bfd2dd15bdb3eac20ccc"}, + {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ff24897f6b2001c38a805d53b6ae72267025878d35ea225aa24675fbff2dba7f"}, + {file = "regex-2021.10.8-cp310-cp310-win32.whl", hash = "sha256:c6569ba7b948c3d61d27f04e2b08ebee24fec9ff8e9ea154d8d1e975b175bfa7"}, + {file = "regex-2021.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:45cb0f7ff782ef51bc79e227a87e4e8f24bc68192f8de4f18aae60b1d60bc152"}, + {file = "regex-2021.10.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fab3ab8aedfb443abb36729410403f0fe7f60ad860c19a979d47fb3eb98ef820"}, + {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e55f8d66f1b41d44bc44c891bcf2c7fad252f8f323ee86fba99d71fd1ad5e3"}, + {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d52c5e089edbdb6083391faffbe70329b804652a53c2fdca3533e99ab0580d9"}, + {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1abbd95cbe9e2467cac65c77b6abd9223df717c7ae91a628502de67c73bf6838"}, + {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9b5c215f3870aa9b011c00daeb7be7e1ae4ecd628e9beb6d7e6107e07d81287"}, + {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f540f153c4f5617bc4ba6433534f8916d96366a08797cbbe4132c37b70403e92"}, + {file = "regex-2021.10.8-cp36-cp36m-win32.whl", hash = "sha256:1f51926db492440e66c89cd2be042f2396cf91e5b05383acd7372b8cb7da373f"}, + {file = "regex-2021.10.8-cp36-cp36m-win_amd64.whl", hash = "sha256:5f55c4804797ef7381518e683249310f7f9646da271b71cb6b3552416c7894ee"}, + {file = "regex-2021.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb2baff66b7d2267e07ef71e17d01283b55b3cc51a81b54cc385e721ae172ba4"}, + {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e527ab1c4c7cf2643d93406c04e1d289a9d12966529381ce8163c4d2abe4faf"}, + {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c98b013273e9da5790ff6002ab326e3f81072b4616fd95f06c8fa733d2745f"}, + {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55ef044899706c10bc0aa052f2fc2e58551e2510694d6aae13f37c50f3f6ff61"}, + {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0ab3530a279a3b7f50f852f1bab41bc304f098350b03e30a3876b7dd89840e"}, + {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a37305eb3199d8f0d8125ec2fb143ba94ff6d6d92554c4b8d4a8435795a6eccd"}, + {file = "regex-2021.10.8-cp37-cp37m-win32.whl", hash = "sha256:2efd47704bbb016136fe34dfb74c805b1ef5c7313aef3ce6dcb5ff844299f432"}, + {file = "regex-2021.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:924079d5590979c0e961681507eb1773a142553564ccae18d36f1de7324e71ca"}, + {file = "regex-2021.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b09d3904bf312d11308d9a2867427479d277365b1617e48ad09696fa7dfcdf59"}, + {file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f125fce0a0ae4fd5c3388d369d7a7d78f185f904c90dd235f7ecf8fe13fa741"}, + {file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f199419a81c1016e0560c39773c12f0bd924c37715bffc64b97140d2c314354"}, + {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:09e1031e2059abd91177c302da392a7b6859ceda038be9e015b522a182c89e4f"}, + {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c070d5895ac6aeb665bd3cd79f673775caf8d33a0b569e98ac434617ecea57d"}, + {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:176796cb7f82a7098b0c436d6daac82f57b9101bb17b8e8119c36eecf06a60a3"}, + {file = "regex-2021.10.8-cp38-cp38-win32.whl", hash = "sha256:5e5796d2f36d3c48875514c5cd9e4325a1ca172fc6c78b469faa8ddd3d770593"}, + {file = "regex-2021.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:e4204708fa116dd03436a337e8e84261bc8051d058221ec63535c9403a1582a1"}, + {file = "regex-2021.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b8b6ee6555b6fbae578f1468b3f685cdfe7940a65675611365a7ea1f8d724991"}, + {file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973499dac63625a5ef9dfa4c791aa33a502ddb7615d992bdc89cf2cc2285daa3"}, + {file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88dc3c1acd3f0ecfde5f95c32fcb9beda709dbdf5012acdcf66acbc4794468eb"}, + {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4786dae85c1f0624ac77cb3813ed99267c9adb72e59fdc7297e1cf4d6036d493"}, + {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe6ce4f3d3c48f9f402da1ceb571548133d3322003ce01b20d960a82251695d2"}, + {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9e3e2cea8f1993f476a6833ef157f5d9e8c75a59a8d8b0395a9a6887a097243b"}, + {file = "regex-2021.10.8-cp39-cp39-win32.whl", hash = "sha256:82cfb97a36b1a53de32b642482c6c46b6ce80803854445e19bc49993655ebf3b"}, + {file = "regex-2021.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:b04e512eb628ea82ed86eb31c0f7fc6842b46bf2601b66b1356a7008327f7700"}, + {file = "regex-2021.10.8.tar.gz", hash = "sha256:26895d7c9bbda5c52b3635ce5991caa90fbb1ddfac9c9ff1c7ce505e2282fb2a"}, ] requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, @@ -1784,9 +1760,8 @@ virtualenv = [ {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"}, ] xdoctest = [ - {file = "xdoctest-0.15.9-py2.py3-none-any.whl", hash = "sha256:0aa507cd2260e8d88adf1f8ef93d1710b78f2c0c21f007ea95562a8f3be7cd71"}, - {file = "xdoctest-0.15.9-py3-none-any.whl", hash = "sha256:1c7a713cab41ec50e0ebca1ad5a8e02c2202f1b81f963fa970fec9c87bbcd4f7"}, - {file = "xdoctest-0.15.9.tar.gz", hash = "sha256:84b00921d9fd6df774b1c5f23a96c65cc35ce78147679b31af95507670547655"}, + {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, + {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, ] zipp = [ {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, diff --git a/pyproject.toml b/pyproject.toml index 11df827..85ea15c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mock-alchemy" -version = "0.2.3" +version = "0.2.4" description = "SQLAlchemy mock helpers." license = "MIT" homepage = "https://github.com/rajivsarvepalli/mock-alchemy" @@ -51,11 +51,11 @@ sphinx-copybutton = "^0.4.0" pydata-sphinx-theme = "^0.7.0" pep8-naming = "^0.11.1" Pygments = "^2.9.0" -reorder-python-imports = "^2.3.6" sphinx-autodoc-typehints = "^1.12.0" flake8-rst-docstrings = "^0.2.3" pre-commit = "^2.9.3" pre-commit-hooks = "^4.0.1" +isort = "^5.9.3" [build-system] requires = ["poetry-core>=1.0.0"] @@ -71,3 +71,7 @@ source = ["mock_alchemy"] [tool.coverage.report] show_missing = true fail_under = 100 + +[tool.isort] +src_paths = ["src", "test"] +force_single_line = true diff --git a/requirements-dev.txt b/requirements-dev.txt index c4874be..51bf19f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,7 +23,7 @@ pydata-sphinx-theme pytest pytest-cov nox -reorder-python-imports +isort flake8-rst-docstrings pre-commit pre-commit-hooks diff --git a/src/mock_alchemy/mocking.py b/src/mock_alchemy/mocking.py index 2f7caa6..f9de652 100644 --- a/src/mock_alchemy/mocking.py +++ b/src/mock_alchemy/mocking.py @@ -12,9 +12,9 @@ from typing import Iterator from typing import List from typing import Optional -from typing import overload from typing import Sequence from typing import Set +from typing import overload from unittest import mock from sqlalchemy.orm.exc import MultipleResultsFound @@ -24,6 +24,7 @@ from .utils import build_identity_map from .utils import copy_and_update from .utils import get_item_attr +from .utils import get_scalar from .utils import indexof from .utils import raiser from .utils import setattr_tmp @@ -311,9 +312,11 @@ class UnifiedAlchemyMagicMock(AlchemyMagicMock): 1 >>> s.query('bar').filter(c == 'one').filter(c == 'two').first() - # .one() + # .one() and scalar >>> s.query('foo').filter(c == 'three').one() 3 + >>> s.query('foo').filter(c == 'three').scalar() + 3 >>> s.query('bar').filter(c == 'one').filter(c == 'two').one_or_none() # .get() @@ -414,6 +417,8 @@ class UnifiedAlchemyMagicMock(AlchemyMagicMock): 1 >>> s.query(SomeClass).delete() 0 + >>> s.query(SomeClass).scalar() + None Also note that only within same query functions are unified. After ``.all()`` is called or query is iterated over, future queries @@ -443,6 +448,7 @@ class UnifiedAlchemyMagicMock(AlchemyMagicMock): else None ), "get": lambda x, idmap: get_item_attr(build_identity_map(x), idmap), + "scalar": lambda x: get_scalar(x), } unify: Dict[str, Optional[UnorderedCall]] = { "query": None, diff --git a/src/mock_alchemy/utils.py b/src/mock_alchemy/utils.py index 9bc4a2d..f32c766 100644 --- a/src/mock_alchemy/utils.py +++ b/src/mock_alchemy/utils.py @@ -12,6 +12,7 @@ from typing import Union from sqlalchemy import inspect +from sqlalchemy.orm.exc import MultipleResultsFound def match_type( @@ -258,3 +259,14 @@ def get_item_attr(idmap: Dict, access: Union[Dict, Tuple, Any]) -> Any: return idmap.get(access) else: return idmap.get((access,)) + + +def get_scalar(rows: Sequence[Any]) -> Any: + """Utility for mocking sqlalchemy.orm.Query.scalar().""" + if len(rows) == 1: + key = rows[0].__table__.columns.keys()[0] + return getattr(rows[0], key) + elif len(rows) > 1: + raise MultipleResultsFound("Multiple rows were found for scalar()") + else: + return None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..835dd5f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Testing package for mock-alchemy.""" diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..9239673 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,93 @@ +"""Common data models for testing.""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import Column +from sqlalchemy import Float +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class SomeClass(Base): + """SQLAlchemy object for testing.""" + + __tablename__ = "some_table" + pk1 = Column(Integer, primary_key=True) + pk2 = Column(Integer, primary_key=True) + name = Column(String(50)) + + def __repr__(self) -> str: + """Get string of object.""" + return str(self.pk1) + + def __eq__(self, other: SomeClass) -> bool: + """Object equality checker.""" + if isinstance(other, SomeClass): + return ( + self.pk1 == other.pk1 + and self.pk2 == other.pk2 + and self.name == other.name + ) + + +class Model(Base): + """SQLAlchemy object for testing.""" + + __tablename__ = "model_table" + pk1 = Column(Integer, primary_key=True) + name = Column(String) + + def __eq__(self, other: Model) -> bool: + """Object equality checker.""" + if isinstance(other, Model): + return self.pk1 == other.pk1 and self.name == other.name + return NotImplemented + + def __repr__(self) -> str: + """Get string of object.""" + return str(self.pk1) + + +class Data(Base): + """SQLAlchemy object for testing.""" + + __tablename__ = "data_table" + pk1 = Column(Integer, primary_key=True) + data_p1 = Column(Float) + data_p2 = Column(Float) + name = Column(String) + + def __repr__(self) -> str: + """Get string of object.""" + return str(self.pk1) + self.name + + +class BaseModel(Base): + """Abstract data model to test.""" + + __abstract__ = True + created = Column(Integer, nullable=False, default=3) + createdby = Column(Integer, nullable=False, default={}) + updated = Column(Integer, nullable=False, default=1) + updatedby = Column(Integer, nullable=False, default={}) + disabled = Column(Integer, nullable=True) + + +class Concrete(BaseModel): + """A testing SQLAlchemy object.""" + + __tablename__ = "concrete" + id = Column(Integer, primary_key=True) + + def __init__(self, **kwargs: Any) -> None: + """Creates a Concrete object.""" + self.id = kwargs.pop("id") + super(Concrete, self).__init__(**kwargs) + + def __eq__(self, other: Concrete) -> bool: + """Equality override.""" + return self.id == other.id diff --git a/tests/test_mocking.py b/tests/test_mocking.py index be8661f..c0fd70f 100644 --- a/tests/test_mocking.py +++ b/tests/test_mocking.py @@ -1,24 +1,27 @@ """Testing the module for mocking in mock-alchemy.""" from __future__ import annotations -from typing import Any from unittest import mock import pytest from sqlalchemy import Column -from sqlalchemy import Float -from sqlalchemy import Integer -from sqlalchemy import or_ from sqlalchemy import String +from sqlalchemy import or_ from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm.exc import MultipleResultsFound from sqlalchemy.sql.expression import column from mock_alchemy.comparison import ExpressionMatcher from mock_alchemy.mocking import AlchemyMagicMock -from mock_alchemy.mocking import sqlalchemy_call from mock_alchemy.mocking import UnifiedAlchemyMagicMock from mock_alchemy.mocking import UnorderedCall from mock_alchemy.mocking import UnorderedTuple +from mock_alchemy.mocking import sqlalchemy_call + +from .common import Concrete +from .common import Data +from .common import Model +from .common import SomeClass Base = declarative_base() @@ -76,29 +79,6 @@ def test_unified_magic_mock() -> None: assert 2 == s.filter.call_count _ = s.filter.assert_any_call(c == "one", c == "two") _ = s.filter.assert_any_call(c == "three", c == "four") - - class SomeClass(Base): - """SQLAlchemy object for testing.""" - - __tablename__ = "some_table" - pk1 = Column(Integer, primary_key=True) - pk2 = Column(Integer, primary_key=True) - name = Column(String(50)) - - def __repr__(self) -> str: - """Get string of object.""" - return str(self.pk1) - - def __eq__(self, other: SomeClass) -> bool: - """Object equality checker.""" - if isinstance(other, SomeClass): - return ( - self.pk1 == other.pk1 - and self.pk2 == other.pk2 - and self.name == other.name - ) - return NotImplemented - s = UnifiedAlchemyMagicMock( data=[ ( @@ -153,24 +133,6 @@ def __eq__(self, other: SomeClass) -> bool: assert ret == 2 ret = s.query(SomeClass).delete() assert ret == 0 - - class Model(Base): - """SQLAlchemy object for testing.""" - - __tablename__ = "model_table" - pk1 = Column(Integer, primary_key=True) - name = Column(String) - - def __eq__(self, other: Model) -> bool: - """Object equality checker.""" - if isinstance(other, Model): - return self.pk1 == other.pk1 and self.name == other.name - return NotImplemented - - def __repr__(self) -> str: - """Get string of object.""" - return str(self.pk1) - s = UnifiedAlchemyMagicMock() s.add_all([SomeClass(pk1=2, pk2=2)]) s.add_all([Model(pk1=5, name="test")]) @@ -205,39 +167,27 @@ def __repr__(self) -> str: assert ret == Model(pk1=2, name="test2") ret = s.query(Model).get({"pk1": 2}) assert ret == Model(pk1=2, name="test2") - - class Model2(Base): - """SQLAlchemy object for testing.""" - - __tablename__ = "model_table_2" - pk1 = Column(Integer, primary_key=True) - name = Column(String) - - def __repr__(self) -> str: - """Get string of object.""" - return str(self.pk1) - s = UnifiedAlchemyMagicMock( data=[ ( - [mock.call.query(Model2), mock.call.filter(Model2.pk1 < 1)], - [Model2(pk1=1, name="test1")], + [mock.call.query(Model), mock.call.filter(Model.pk1 < 1)], + [Model(pk1=1, name="test1")], ), ( - [mock.call.query(Model2)], - [Model2(pk1=2, name="test2")], + [mock.call.query(Model)], + [Model(pk1=2, name="test2")], ), ] ) - ret = s.query(Model2).filter(Model2.pk1 < 1).all() + ret = s.query(Model).filter(Model.pk1 < 1).all() ret = [str(r) for r in ret] assert ret == ["1"] - ret = s.query(Model2).filter(Model2.pk1 < 1).delete() + ret = s.query(Model).filter(Model.pk1 < 1).delete() assert ret == 1 - ret = s.query(Model2).all() + ret = s.query(Model).all() ret = [str(r) for r in ret] assert ret == ["2"] - ret = s.query(Model2).filter(Model2.pk1 < 1).all() + ret = s.query(Model).filter(Model.pk1 < 1).all() assert ret == [] s = UnifiedAlchemyMagicMock() ret = s.query(Model).delete() @@ -246,20 +196,6 @@ def __repr__(self) -> str: def test_complex_session() -> None: """Tests mock for SQLAlchemy with more complex session.""" - - class Data(Base): - """SQLAlchemy object for testing.""" - - __tablename__ = "data_table" - pk1 = Column(Integer, primary_key=True) - data_p1 = Column(Float) - data_p2 = Column(Float) - name = Column(String) - - def __repr__(self) -> str: - """Get string of object.""" - return str(self.pk1) + self.name - s = UnifiedAlchemyMagicMock( data=[ ( @@ -312,32 +248,6 @@ def __repr__(self) -> str: def test_abstract_classes() -> None: """Tests mock for SQLAlchemy with inheritance and abstract classes.""" - - class BaseModel(Base): - """Abstract data model to test.""" - - __abstract__ = True - created = Column(Integer, nullable=False, default=3) - createdby = Column(Integer, nullable=False, default={}) - updated = Column(Integer, nullable=False, default=1) - updatedby = Column(Integer, nullable=False, default={}) - disabled = Column(Integer, nullable=True) - - class Concrete(BaseModel): - """A testing SQLAlchemy object.""" - - __tablename__ = "concrete" - id = Column(Integer, primary_key=True) - - def __init__(self, **kwargs: Any) -> None: - """Creates a Concrete object.""" - self.id = kwargs.pop("id") - super(Concrete, self).__init__(**kwargs) - - def __eq__(self, other: Concrete) -> bool: - """Equality override.""" - return self.id == other.id - objs = Concrete(id=1) session = UnifiedAlchemyMagicMock( data=[ @@ -350,39 +260,63 @@ def __eq__(self, other: Concrete) -> bool: def test_get_tuple() -> None: """Tests mock for SQLAlchemy with getting by a tuple.""" - - class SomeObject(Base): - """SQLAlchemy object for testing.""" - - __tablename__ = "some_table1" - pk1 = Column(String, primary_key=True) - name = Column(String(50)) - - def __repr__(self) -> str: - """Get string of object.""" - return str(self.pk1) - mock_session = UnifiedAlchemyMagicMock() - mock_session.add(SomeObject(pk1="123", name="test")) - user = mock_session.query(SomeObject).get(("123",)) + mock_session.add(Model(pk1="123", name="test")) + user = mock_session.query(Model).get(("123",)) assert user is not None def test_get_singular() -> None: """Tests mock for SQLAlchemy with getting by a singular value.""" + mock_session = UnifiedAlchemyMagicMock() + mock_session.add(Model(pk1="123", name="test")) + user = mock_session.query(Model).get("123") + assert user is not None + + +def test_scalar_singular() -> None: + """Tests mock for SQLAlchemy with scalar when there is one row.""" + mock_session = UnifiedAlchemyMagicMock() + mock_session.add(Model(pk1="123", name="test")) + data_pk1 = mock_session.query(Model).scalar() + assert data_pk1 == "123" - class SomeData(Base): - """SQLAlchemy object for testing.""" - __tablename__ = "some_table2" - pk1 = Column(String, primary_key=True) - name = Column(String(50)) +def test_scalar_none() -> None: + """Tests mock for SQLAlchemy with scalar when rows are empty.""" + mock_session = UnifiedAlchemyMagicMock() + data = mock_session.query(Model).scalar() + assert data is None - def __repr__(self) -> str: - """Get string of object.""" - return str(self.pk1) +def test_scalar_multiple() -> None: + """Tests mock for SQLAlchemy with scalar when there are many rows.""" mock_session = UnifiedAlchemyMagicMock() - mock_session.add(SomeData(pk1="123", name="test")) - user = mock_session.query(SomeData).get("123") - assert user is not None + mock_session.add(Model(pk1="123", name="test")) + mock_session.add(Model(pk1="1234", name="test")) + with pytest.raises(MultipleResultsFound): + mock_session.query(Model).scalar() + + +def test_scalar_attribute() -> None: + """Tests mock for SQLAlchemy with scalar for getting an attribute.""" + + class Attribute(Base): + """SQLAlchemy object for testing.""" + + __tablename__ = "attribute" + name = Column(String(50), primary_key=True) + + mock_session = UnifiedAlchemyMagicMock( + data=[ + ( + [ + mock.call.query(Model.name), + mock.call.filter(Model.pk1 == 3), + ], + [Attribute(name="test")], + ) + ] + ) + data_name = mock_session.query(Model.name).filter(Model.pk1 == 3).scalar() + assert data_name == "test" diff --git a/tests/test_utils.py b/tests/test_utils.py index dbea1ad..bd0b6d5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,18 +1,19 @@ """Testing the module for utils in mock-alchemy.""" import pytest -from sqlalchemy import Column -from sqlalchemy import Integer -from sqlalchemy import String from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm.exc import MultipleResultsFound from mock_alchemy.utils import build_identity_map from mock_alchemy.utils import copy_and_update from mock_alchemy.utils import get_item_attr +from mock_alchemy.utils import get_scalar from mock_alchemy.utils import indexof from mock_alchemy.utils import match_type from mock_alchemy.utils import raiser from mock_alchemy.utils import setattr_tmp +from .common import SomeClass + Base = declarative_base() @@ -70,16 +71,6 @@ def test_func(x: bool) -> None: def test_idmap() -> None: """Tests building an idmap.""" - - class SomeClass(Base): - __tablename__ = "some_table" - pk1 = Column(Integer, primary_key=True) - pk2 = Column(Integer, primary_key=True) - name = Column(String(50)) - - def __repr__(self) -> str: - return str(self.pk1) - expected_idmap = {(1, 2): 1} idmap = build_identity_map([SomeClass(pk1=1, pk2=2)]) assert str(expected_idmap) == str(idmap) @@ -91,3 +82,13 @@ def test_get_attr() -> None: assert 2 == get_item_attr(idmap, 1) assert 2 == get_item_attr(idmap, {"pk": 1}) assert 2 == get_item_attr(idmap, (1,)) + + +def test_get_scalar() -> None: + """Tests utility for getting scalar values.""" + assert 1 == get_scalar([SomeClass(pk1=1, pk2=2)]) + assert None is get_scalar([SomeClass(pk1=None, pk2=2)]) + assert None is get_scalar([]) + with pytest.raises(MultipleResultsFound): + get_scalar([SomeClass(pk1=1, pk2=2), SomeClass(pk1=2, pk2=3)]) + assert None is get_scalar([SomeClass(pk1=None, pk2=None)])