├── quantaq ├── exceptions.py ├── __init__.py ├── endpoints │ ├── __init__.py │ ├── organizations │ │ └── __init__.py │ ├── solar │ │ └── __init__.py │ ├── cellular │ │ └── __init__.py │ ├── models │ │ └── __init__.py │ ├── networks │ │ └── __init__.py │ ├── users │ │ └── __init__.py │ ├── logs │ │ └── __init__.py │ ├── devices │ │ └── __init__.py │ └── data │ │ └── __init__.py ├── utils.py └── client.py ├── docs ├── generated │ ├── quantaq.utils.to_dataframe.rst │ ├── quantaq.endpoints.data.Data.get.rst │ ├── quantaq.endpoints.logs.Logs.get.rst │ ├── quantaq.endpoints.data.Data.list.rst │ ├── quantaq.endpoints.logs.Logs.drop.rst │ ├── quantaq.endpoints.logs.Logs.list.rst │ ├── quantaq.endpoints.users.Users.get.rst │ ├── quantaq.endpoints.data.Data.bydate.rst │ ├── quantaq.endpoints.logs.Logs.update.rst │ ├── quantaq.endpoints.models.Models.add.rst │ ├── quantaq.endpoints.models.Models.get.rst │ ├── quantaq.endpoints.solar.Solar.list.rst │ ├── quantaq.endpoints.users.Users.list.rst │ ├── quantaq.endpoints.users.Users.update.rst │ ├── quantaq.endpoints.devices.Devices.add.rst │ ├── quantaq.endpoints.devices.Devices.get.rst │ ├── quantaq.endpoints.devices.Devices.drop.rst │ ├── quantaq.endpoints.devices.Devices.list.rst │ ├── quantaq.endpoints.cellular.Cellular.drop.rst │ ├── quantaq.endpoints.cellular.Cellular.list.rst │ ├── quantaq.endpoints.devices.Devices.update.rst │ ├── quantaq.endpoints.networks.Networks.get.rst │ ├── quantaq.endpoints.networks.Networks.list.rst │ ├── quantaq.endpoints.organizations.Organizations.get.rst │ ├── quantaq.endpoints.organizations.Organizations.list.rst │ ├── quantaq.client.ClientBase.rst │ ├── quantaq.client.APIClient.rst │ ├── quantaq.QuantAQAPIClient.rst │ ├── quantaq.client.StagingAPIClient.rst │ ├── quantaq.client.ProductionAPIClient.rst │ └── quantaq.client.DevelopmentAPIClient.rst ├── Makefile ├── make.bat ├── conf.py ├── index.rst ├── api.rst └── usage.rst ├── .github └── workflows │ ├── release-to-pypi.yml │ ├── test-and-verify.yml │ └── build-and-deploy-docs.yml ├── pyproject.toml ├── deploy.sh ├── tests ├── test_models.py ├── test_orgs.py ├── test_networks.py ├── test_cellular.py ├── test_logs.py ├── test_basics.py ├── test_users.py ├── test_devices.py └── test_data.py ├── .gitignore ├── README.md ├── LICENSE └── poetry.lock /quantaq/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class QuantAQAPIException(Exception): 4 | pass -------------------------------------------------------------------------------- /docs/generated/quantaq.utils.to_dataframe.rst: -------------------------------------------------------------------------------- 1 | quantaq.utils.to\_dataframe 2 | =========================== 3 | 4 | .. currentmodule:: quantaq.utils 5 | 6 | .. autofunction:: to_dataframe -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.data.Data.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.data.Data.get 2 | =============================== 3 | 4 | .. currentmodule:: quantaq.endpoints.data 5 | 6 | .. automethod:: Data.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.logs.Logs.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.logs.Logs.get 2 | =============================== 3 | 4 | .. currentmodule:: quantaq.endpoints.logs 5 | 6 | .. automethod:: Logs.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.data.Data.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.data.Data.list 2 | ================================ 3 | 4 | .. currentmodule:: quantaq.endpoints.data 5 | 6 | .. automethod:: Data.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.logs.Logs.drop.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.logs.Logs.drop 2 | ================================ 3 | 4 | .. currentmodule:: quantaq.endpoints.logs 5 | 6 | .. automethod:: Logs.drop -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.logs.Logs.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.logs.Logs.list 2 | ================================ 3 | 4 | .. currentmodule:: quantaq.endpoints.logs 5 | 6 | .. automethod:: Logs.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.users.Users.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.users.Users.get 2 | ================================= 3 | 4 | .. currentmodule:: quantaq.endpoints.users 5 | 6 | .. automethod:: Users.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.data.Data.bydate.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.data.Data.bydate 2 | ================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.data 5 | 6 | .. automethod:: Data.bydate -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.logs.Logs.update.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.logs.Logs.update 2 | ================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.logs 5 | 6 | .. automethod:: Logs.update -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.models.Models.add.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.models.Models.add 2 | =================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.models 5 | 6 | .. automethod:: Models.add -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.models.Models.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.models.Models.get 2 | =================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.models 5 | 6 | .. automethod:: Models.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.solar.Solar.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.solar.Solar.list 2 | ================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.solar 5 | 6 | .. automethod:: Solar.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.users.Users.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.users.Users.list 2 | ================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.users 5 | 6 | .. automethod:: Users.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.users.Users.update.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.users.Users.update 2 | ==================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.users 5 | 6 | .. automethod:: Users.update -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.devices.Devices.add.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.devices.Devices.add 2 | ===================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.devices 5 | 6 | .. automethod:: Devices.add -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.devices.Devices.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.devices.Devices.get 2 | ===================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.devices 5 | 6 | .. automethod:: Devices.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.devices.Devices.drop.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.devices.Devices.drop 2 | ====================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.devices 5 | 6 | .. automethod:: Devices.drop -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.devices.Devices.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.devices.Devices.list 2 | ====================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.devices 5 | 6 | .. automethod:: Devices.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.cellular.Cellular.drop.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.cellular.Cellular.drop 2 | ======================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.cellular 5 | 6 | .. automethod:: Cellular.drop -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.cellular.Cellular.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.cellular.Cellular.list 2 | ======================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.cellular 5 | 6 | .. automethod:: Cellular.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.devices.Devices.update.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.devices.Devices.update 2 | ======================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.devices 5 | 6 | .. automethod:: Devices.update -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.networks.Networks.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.networks.Networks.get 2 | ======================================= 3 | 4 | .. currentmodule:: quantaq.endpoints.networks 5 | 6 | .. automethod:: Networks.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.networks.Networks.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.networks.Networks.list 2 | ======================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.networks 5 | 6 | .. automethod:: Networks.list -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.organizations.Organizations.get.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.organizations.Organizations.get 2 | ================================================= 3 | 4 | .. currentmodule:: quantaq.endpoints.organizations 5 | 6 | .. automethod:: Organizations.get -------------------------------------------------------------------------------- /docs/generated/quantaq.endpoints.organizations.Organizations.list.rst: -------------------------------------------------------------------------------- 1 | quantaq.endpoints.organizations.Organizations.list 2 | ================================================== 3 | 4 | .. currentmodule:: quantaq.endpoints.organizations 5 | 6 | .. automethod:: Organizations.list -------------------------------------------------------------------------------- /quantaq/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from importlib.metadata import version 3 | import quantaq 4 | 5 | __version__ = version("py-quantaq") 6 | 7 | from .client import ( 8 | ClientBase, 9 | DevelopmentAPIClient, 10 | StagingAPIClient, 11 | ProductionAPIClient 12 | ) 13 | 14 | QuantAQAPIClient = ProductionAPIClient 15 | -------------------------------------------------------------------------------- /quantaq/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | GET = "GET" 4 | PUT = "PUT" 5 | DELETE = "DELETE" 6 | POST = "POST" 7 | 8 | class Domain(object): 9 | """ 10 | This represents a single group of endpoints. 11 | """ 12 | def __init__(self, client) -> None: 13 | """ 14 | :param quantaq.client.ClientBase client: The API base client. 15 | """ 16 | self.client = client -------------------------------------------------------------------------------- /docs/generated/quantaq.client.ClientBase.rst: -------------------------------------------------------------------------------- 1 | quantaq.client.ClientBase 2 | ========================= 3 | 4 | .. currentmodule:: quantaq.client 5 | 6 | .. autoclass:: ClientBase 7 | 8 | 9 | .. automethod:: __init__ 10 | 11 | 12 | .. rubric:: Methods 13 | 14 | .. autosummary:: 15 | 16 | ~ClientBase.__init__ 17 | ~ClientBase.paginate 18 | ~ClientBase.request 19 | ~ClientBase.requests 20 | ~ClientBase.url 21 | 22 | 23 | 24 | 25 | 26 | .. rubric:: Attributes 27 | 28 | .. autosummary:: 29 | 30 | ~ClientBase.auth 31 | ~ClientBase.headers 32 | 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /quantaq/utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | def to_dataframe(data, **kwargs) -> pd.DataFrame: 5 | """Convert an array of dictionaries into a pd.DataFrame. 6 | 7 | :param list data: Data in the form of a list of dicts 8 | :param bool force_ts: Force timestamp columns to timestamps 9 | 10 | :returns: DataFrame with all data 11 | :rtype: pd.DataFrame 12 | """ 13 | force_ts = kwargs.pop("force_ts", True) 14 | 15 | if not isinstance(data, list): 16 | raise TypeError("Current data must be a list") 17 | 18 | data = pd.json_normalize(data) 19 | 20 | if force_ts: 21 | for col in [c for c in data.columns if "timestamp" in c.lower()]: 22 | try: 23 | data[col] = data[col].apply(pd.to_datetime) 24 | except: #pragma: no cover 25 | pass 26 | 27 | return data 28 | -------------------------------------------------------------------------------- /docs/generated/quantaq.client.APIClient.rst: -------------------------------------------------------------------------------- 1 | quantaq.client.APIClient 2 | ======================== 3 | 4 | .. currentmodule:: quantaq.client 5 | 6 | .. autoclass:: APIClient 7 | 8 | 9 | .. automethod:: __init__ 10 | 11 | 12 | .. rubric:: Methods 13 | 14 | .. autosummary:: 15 | 16 | ~APIClient.__init__ 17 | ~APIClient.paginate 18 | ~APIClient.request 19 | ~APIClient.requests 20 | ~APIClient.url 21 | ~APIClient.whoami 22 | 23 | 24 | 25 | 26 | 27 | .. rubric:: Attributes 28 | 29 | .. autosummary:: 30 | 31 | ~APIClient.auth 32 | ~APIClient.cellular 33 | ~APIClient.data 34 | ~APIClient.devices 35 | ~APIClient.headers 36 | ~APIClient.logs 37 | ~APIClient.models 38 | ~APIClient.networks 39 | ~APIClient.organizations 40 | ~APIClient.solar 41 | ~APIClient.users 42 | 43 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/workflows/release-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: build & release 2 | on: 3 | create: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish to PyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout branch 13 | uses: actions/checkout@v2 14 | 15 | - name: Setup python 16 | uses: actions/setup-python@master 17 | with: 18 | python-version: 3.8 19 | 20 | - name: Install poetry 21 | uses: snok/install-poetry@v1 22 | with: 23 | virtualenvs-create: true 24 | 25 | - name: Install dependencies 26 | run: poetry install 27 | 28 | - name: Build and publish to PyPI 29 | run: | 30 | poetry build 31 | poetry publish -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "py-quantaq" 3 | version = "1.4.0a1" 4 | description = "API wrapper and utils for QuantAQ, Inc." 5 | authors = ["David H Hagan "] 6 | license = "Apache License 2.0" 7 | readme = "README.md" 8 | homepage = "https://quant-aq.github.io/py-quantaq" 9 | repository = "https://github.com/quant-aq/py-quantaq" 10 | documentation = "https://quant-aq.github.io/py-quantaq" 11 | keywords = [] 12 | classifiers = [] 13 | packages = [ 14 | { include = "quantaq", from='.' } 15 | ] 16 | 17 | [tool.poetry.dependencies] 18 | python = ">=3.8, <3.12" 19 | requests = "^2.24" 20 | pandas = ">=1.0" 21 | setuptools = ">=47.0" 22 | urllib3 = ">=1.26.5" 23 | numpy = ">=1.22" 24 | 25 | [tool.poetry.dev-dependencies] 26 | pytest = "^7.1" 27 | pytest-coverage = "^0.0" 28 | sphinx = "^4.2.0" 29 | toml = "^0.10.1" 30 | responses = "^0.10.15" 31 | sphinx-rtd-theme = "^0.5.0" 32 | 33 | [build-system] 34 | requires = ["poetry>=0.12"] 35 | build-backend = "poetry.masonry.api" 36 | -------------------------------------------------------------------------------- /quantaq/endpoints/organizations/__init__.py: -------------------------------------------------------------------------------- 1 | from quantaq.endpoints import Domain 2 | 3 | 4 | class Organizations(Domain): 5 | def list(self, **kwargs): 6 | """ 7 | Return a list of organizations accessible by the account. 8 | 9 | :param str limit: Limit the number of results returned 10 | :param str sort: Sort the results by a specific attribute 11 | :param str filter: Filter the query 12 | :param int per_page: Define the number of results to return per page 13 | 14 | :returns: Organizations 15 | :rtype: list of dict 16 | """ 17 | return self.client.requests("orgs/", **kwargs) 18 | 19 | def get(self, **kwargs): 20 | """ 21 | Return organizations with id = id. 22 | 23 | :param int id: The organization id 24 | 25 | :returns: Organizations information 26 | :rtype: dict 27 | """ 28 | id = kwargs.pop("id") 29 | 30 | return self.client.requests("orgs/" + str(id)) 31 | -------------------------------------------------------------------------------- /docs/generated/quantaq.QuantAQAPIClient.rst: -------------------------------------------------------------------------------- 1 | quantaq.QuantAQAPIClient 2 | ======================== 3 | 4 | .. currentmodule:: quantaq 5 | 6 | .. autoclass:: QuantAQAPIClient 7 | 8 | 9 | .. automethod:: __init__ 10 | 11 | 12 | .. rubric:: Methods 13 | 14 | .. autosummary:: 15 | 16 | ~QuantAQAPIClient.__init__ 17 | ~QuantAQAPIClient.paginate 18 | ~QuantAQAPIClient.request 19 | ~QuantAQAPIClient.requests 20 | ~QuantAQAPIClient.url 21 | ~QuantAQAPIClient.whoami 22 | 23 | 24 | 25 | 26 | 27 | .. rubric:: Attributes 28 | 29 | .. autosummary:: 30 | 31 | ~QuantAQAPIClient.auth 32 | ~QuantAQAPIClient.cellular 33 | ~QuantAQAPIClient.data 34 | ~QuantAQAPIClient.devices 35 | ~QuantAQAPIClient.headers 36 | ~QuantAQAPIClient.logs 37 | ~QuantAQAPIClient.models 38 | ~QuantAQAPIClient.networks 39 | ~QuantAQAPIClient.organizations 40 | ~QuantAQAPIClient.solar 41 | ~QuantAQAPIClient.users 42 | 43 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | # https://python-poetry.org/docs/cli/#version 2 | 3 | # Tag the version 4 | if [ -z "$1" ] 5 | then 6 | echo "Deploy failed" 7 | elif [ "$1" = "patch" ] 8 | then 9 | poetry version patch 10 | elif [ "$1" = "minor" ] 11 | then 12 | poetry version minor 13 | elif [ "$1" = "major" ] 14 | then 15 | poetry version major 16 | elif [ "$1" = "premajor" ] 17 | then 18 | poetry version premajor 19 | elif [ "$1" = "preminor" ] 20 | then 21 | poetry version preminor 22 | elif [ "$1" = "prepatch" ] 23 | then 24 | poetry version prepatch 25 | elif [ "$1" = "prerelease" ] 26 | then 27 | poetry version prerelease 28 | else 29 | echo "Deploy failed." 30 | fi 31 | 32 | # Tag 33 | VERSION=`poetry version` 34 | VERSION_BITS=(${VERSION// }) 35 | NEW_TAG=${VERSION_BITS[1]} 36 | 37 | # commit the changes 38 | git add -A 39 | git commit -m "Pushing a new release candidate" 40 | git push 41 | 42 | git tag $NEW_TAG 43 | git push --tags 44 | echo "Tagged and pushed to GitHub" -------------------------------------------------------------------------------- /docs/generated/quantaq.client.StagingAPIClient.rst: -------------------------------------------------------------------------------- 1 | quantaq.client.StagingAPIClient 2 | =============================== 3 | 4 | .. currentmodule:: quantaq.client 5 | 6 | .. autoclass:: StagingAPIClient 7 | 8 | 9 | .. automethod:: __init__ 10 | 11 | 12 | .. rubric:: Methods 13 | 14 | .. autosummary:: 15 | 16 | ~StagingAPIClient.__init__ 17 | ~StagingAPIClient.paginate 18 | ~StagingAPIClient.request 19 | ~StagingAPIClient.requests 20 | ~StagingAPIClient.url 21 | ~StagingAPIClient.whoami 22 | 23 | 24 | 25 | 26 | 27 | .. rubric:: Attributes 28 | 29 | .. autosummary:: 30 | 31 | ~StagingAPIClient.auth 32 | ~StagingAPIClient.cellular 33 | ~StagingAPIClient.data 34 | ~StagingAPIClient.devices 35 | ~StagingAPIClient.headers 36 | ~StagingAPIClient.logs 37 | ~StagingAPIClient.models 38 | ~StagingAPIClient.networks 39 | ~StagingAPIClient.organizations 40 | ~StagingAPIClient.solar 41 | ~StagingAPIClient.users 42 | 43 | -------------------------------------------------------------------------------- /docs/generated/quantaq.client.ProductionAPIClient.rst: -------------------------------------------------------------------------------- 1 | quantaq.client.ProductionAPIClient 2 | ================================== 3 | 4 | .. currentmodule:: quantaq.client 5 | 6 | .. autoclass:: ProductionAPIClient 7 | 8 | 9 | .. automethod:: __init__ 10 | 11 | 12 | .. rubric:: Methods 13 | 14 | .. autosummary:: 15 | 16 | ~ProductionAPIClient.__init__ 17 | ~ProductionAPIClient.paginate 18 | ~ProductionAPIClient.request 19 | ~ProductionAPIClient.requests 20 | ~ProductionAPIClient.url 21 | ~ProductionAPIClient.whoami 22 | 23 | 24 | 25 | 26 | 27 | .. rubric:: Attributes 28 | 29 | .. autosummary:: 30 | 31 | ~ProductionAPIClient.auth 32 | ~ProductionAPIClient.cellular 33 | ~ProductionAPIClient.data 34 | ~ProductionAPIClient.devices 35 | ~ProductionAPIClient.headers 36 | ~ProductionAPIClient.logs 37 | ~ProductionAPIClient.models 38 | ~ProductionAPIClient.networks 39 | ~ProductionAPIClient.organizations 40 | ~ProductionAPIClient.solar 41 | ~ProductionAPIClient.users 42 | 43 | -------------------------------------------------------------------------------- /docs/generated/quantaq.client.DevelopmentAPIClient.rst: -------------------------------------------------------------------------------- 1 | quantaq.client.DevelopmentAPIClient 2 | =================================== 3 | 4 | .. currentmodule:: quantaq.client 5 | 6 | .. autoclass:: DevelopmentAPIClient 7 | 8 | 9 | .. automethod:: __init__ 10 | 11 | 12 | .. rubric:: Methods 13 | 14 | .. autosummary:: 15 | 16 | ~DevelopmentAPIClient.__init__ 17 | ~DevelopmentAPIClient.paginate 18 | ~DevelopmentAPIClient.request 19 | ~DevelopmentAPIClient.requests 20 | ~DevelopmentAPIClient.url 21 | ~DevelopmentAPIClient.whoami 22 | 23 | 24 | 25 | 26 | 27 | .. rubric:: Attributes 28 | 29 | .. autosummary:: 30 | 31 | ~DevelopmentAPIClient.auth 32 | ~DevelopmentAPIClient.cellular 33 | ~DevelopmentAPIClient.data 34 | ~DevelopmentAPIClient.devices 35 | ~DevelopmentAPIClient.headers 36 | ~DevelopmentAPIClient.logs 37 | ~DevelopmentAPIClient.models 38 | ~DevelopmentAPIClient.networks 39 | ~DevelopmentAPIClient.organizations 40 | ~DevelopmentAPIClient.solar 41 | ~DevelopmentAPIClient.users 42 | 43 | -------------------------------------------------------------------------------- /quantaq/endpoints/solar/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | 8 | class Solar(Domain): 9 | """Initialize the Solar group of endpoints. 10 | 11 | :returns: Domain for Solar 12 | :rtype: quantaq.models.Solar 13 | """ 14 | def __init__(self, client) -> None: 15 | super(Solar, self).__init__(client) 16 | 17 | def list(self, **kwargs) -> list: 18 | """Return all Smart Solar Power System data for device with serial number sn. 19 | 20 | :param str sn: The device serial number 21 | :param str start: Start date for data retrieval 22 | :param str stop: End date for data retrieval 23 | :param str limit: Limit the number of results returned 24 | :param str sort: Sort the results by a specific attribute 25 | :param str filter: Filter the query 26 | :param int per_page: Define the number of results to return per page 27 | 28 | :returns: Data 29 | :rtype: list of dict 30 | """ 31 | sn = kwargs.pop("sn") 32 | endpoint = f"devices/{sn}/data/solar/" 33 | 34 | return self.client.requests(endpoint, **kwargs) -------------------------------------------------------------------------------- /.github/workflows/test-and-verify.yml: -------------------------------------------------------------------------------- 1 | name: run and build 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python: ["3.8", "3.9", "3.10", "3.11"] 9 | name: Python ${{ matrix.python }} tests 10 | steps: 11 | - name: Checkout branch 12 | uses: actions/checkout@v2 13 | 14 | - name: Setup Python ${{ matrix.python }} 15 | uses: actions/setup-python@master 16 | with: 17 | python-version: ${{ matrix.python }} 18 | 19 | - name: Install poetry 20 | uses: snok/install-poetry@v1.3.3 21 | with: 22 | version: 1.2.2 23 | virtualenvs-create: true 24 | virtualenvs-in-project: true 25 | installer-parallel: true 26 | 27 | - name: Install dependencies 28 | run: poetry install --no-interaction 29 | 30 | - name: Run tests and generate coverage report 31 | run: | 32 | poetry run pytest --cov=./ --cov-report=xml 33 | 34 | - name: Upload coverage to Codecov 35 | uses: codecov/codecov-action@v1 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | file: ./coverage.xml 39 | flags: unittests 40 | name: codecov-umbrella 41 | fail_ci_if_error: true -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: docs/gh-pages 2 | on: [workflow_dispatch] 3 | # create: 4 | # tags: 5 | # - '*' 6 | # schedule: 7 | # - cron: '0 3 * * *' # run every night at 3AM 8 | 9 | jobs: 10 | build-docs: 11 | name: Build docs and push to gh-pages 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout branch 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup python 18 | uses: actions/setup-python@master 19 | with: 20 | python-version: 3.8 21 | 22 | - name: Install poetry 23 | uses: snok/install-poetry@v1 24 | with: 25 | virtualenvs-create: true 26 | 27 | - name: Install dependencies 28 | run: poetry install 29 | 30 | - name: build docs 31 | run: | 32 | source $(poetry env info --path)/bin/activate 33 | cd docs 34 | make clean 35 | make html 36 | cd .. 37 | 38 | - name: deploy to gh-pages 39 | uses: Cecilapp/GitHub-Pages-deploy@v3 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | email: david.hagan@quant-aq.com 44 | build_dir: docs/_build/html 45 | -------------------------------------------------------------------------------- /quantaq/endpoints/cellular/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | 8 | class Cellular(Domain): 9 | """Initialize the Cellular logs group of endpoints. 10 | 11 | :returns: Domain for Cellular 12 | :rtype: quantaq.endpoints.Cellular 13 | """ 14 | def __init__(self, client) -> None: 15 | super(Cellular, self).__init__(client) 16 | 17 | def list(self, **kwargs) -> list: 18 | """Return a list of cellular logs for a device with 19 | serial number sn. 20 | 21 | :param str sn: The device serial number 22 | :param str start: Start date for log retrieval 23 | :param str stop: End date for log retrieval 24 | :param str limit: Limit the number of results returned 25 | :param str sort: Sort the results by a specific attribute 26 | :param str filter: Filter the query 27 | :param int per_page: Define the number of results to return per page 28 | 29 | :returns: Cellular Logs 30 | :rtype: list of dict 31 | """ 32 | sn = kwargs.pop("sn") 33 | 34 | return self.client.requests("meta-data/cell-data/{}/".format(sn), **kwargs) 35 | 36 | def drop(self, **kwargs) -> dict: 37 | """Delete a cellular log record. 38 | 39 | :param int id: The id of the cellular log record 40 | 41 | :returns: Status of deletion 42 | :rtype: dict 43 | """ 44 | id = kwargs.pop("id") 45 | 46 | return self.client.requests("meta-data/cell-data/" + str(id), verb=DELETE) -------------------------------------------------------------------------------- /quantaq/endpoints/models/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | 8 | class Models(Domain): 9 | """Initialize the Models group of endpoints. 10 | 11 | :returns: Domain for Models 12 | :rtype: quantaq.endpoints.Models 13 | """ 14 | def __init__(self, client) -> None: 15 | super(Models, self).__init__(client) 16 | 17 | def add(self, **kwargs) -> list: 18 | """Add a new calibration model for device with sn = serial number. 19 | 20 | :param str sn: The device serial number (required) 21 | :param str name: The name of the model 22 | :param str object_name: The object name of the model itself 23 | :param str training_file: The object name of the training data file 24 | :param str param: The parameter [co, co2, no, no2, o3, so2, voc, pm1, pm25, pm10] 25 | :param dict error: Training error information 26 | :param dict model: Model overview and details 27 | :param dict calibration: Calibration and training detials 28 | 29 | :returns: Model information 30 | :rtype: dict 31 | """ 32 | return self.client.requests("calibration-models/", verb=POST, **kwargs) 33 | 34 | def get(self, **kwargs) -> dict: 35 | """Return the calibration models for device with sn = serial number. 36 | 37 | :param str sn: The device serial number 38 | 39 | :returns: Model information 40 | :rtype: list of dict 41 | """ 42 | sn = kwargs.pop("sn") 43 | 44 | return self.client.requests("calibration-models/" + sn) -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | import os 4 | import sys 5 | import pandas as pd 6 | import pytest 7 | 8 | from quantaq.exceptions import QuantAQAPIException 9 | 10 | @responses.activate 11 | def test_models_get(): 12 | responses.add(responses.GET, "https://localhost/device-api/v1/calibration-models/SN000-000", 13 | status=200, 14 | json={ 15 | "co": { 16 | "calibration": None, 17 | "id": 1, 18 | "model": None, 19 | "param": "co" 20 | }, 21 | } 22 | ) 23 | 24 | client = quantaq.client.APIClient( 25 | "https://localhost/device-api/", 26 | api_key="a123", version="v1") 27 | 28 | # test the GET verb 29 | resp = client.models.get(sn="SN000-000") 30 | 31 | assert type(resp) == dict 32 | 33 | @responses.activate 34 | def test_models_add(): 35 | responses.add(responses.POST, "https://localhost/device-api/v1/calibration-models/", 36 | status=201, 37 | json={} 38 | ) 39 | 40 | client = quantaq.client.APIClient( 41 | "https://localhost/device-api/", 42 | api_key="a123", version="v1") 43 | 44 | # test the GET verb 45 | resp = client.models.add( 46 | sn="SN000-000", 47 | param='co', 48 | name='test-co-model', 49 | object_name='file1.sav', 50 | training_file='obj1/training.csv', 51 | error=dict(r2=0.87, rmse=.97, mae=.5) 52 | ) 53 | 54 | assert type(resp) == dict 55 | assert responses.calls[0].response.status_code == 201 56 | -------------------------------------------------------------------------------- /quantaq/endpoints/networks/__init__.py: -------------------------------------------------------------------------------- 1 | from quantaq.endpoints import Domain 2 | 3 | 4 | class Networks(Domain): 5 | """ 6 | Note: the Networks endpoint is scoped within the Organizations endpoint, 7 | so all requests for networks (all or singular) are in the context 8 | of a particular organization. 9 | """ 10 | 11 | def list(self, **kwargs): 12 | """ 13 | Return a list of networks accessible by the account, 14 | in the context of the organization with id=organization_id. 15 | 16 | :param int organization_id: The parent organization id. 17 | :param str limit: Limit the number of results returned 18 | :param str sort: Sort the results by a specific attribute 19 | :param str filter: Filter the query 20 | :param int per_page: Define the number of results to return per page 21 | 22 | :returns: Networks 23 | :rtype: list of dict 24 | """ 25 | organization_id = kwargs.pop("organization_id") 26 | return self.client.requests(f"orgs/{organization_id}/networks/", **kwargs) 27 | 28 | def get(self, **kwargs): 29 | """ 30 | Return network with id=network_id in organization with id=organization_id. 31 | 32 | :param int organization_id: The parent organization id 33 | :param int network_id: The network id 34 | 35 | :returns: Network information 36 | :rtype: dict 37 | """ 38 | organization_id = kwargs.pop("organization_id") 39 | network_id = kwargs.pop("network_id") 40 | 41 | return self.client.requests(f"orgs/{organization_id}/networks/{network_id}", **kwargs) 42 | -------------------------------------------------------------------------------- /quantaq/endpoints/users/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | class Users(Domain): 8 | """Initialize the Users group of endpoints. 9 | 10 | :returns: Domain for Users 11 | :rtype: quantaq.models.Users 12 | """ 13 | def __init__(self, client) -> None: 14 | super(Users, self).__init__(client) 15 | 16 | def list(self, **kwargs): 17 | """Return all (available) users. 18 | 19 | :param str limit: Limit the number of results returned 20 | :param str sort: Sort the results by a specific attribute 21 | :param str filter: Filter the query 22 | :param int per_page: Define the number of results to return per page 23 | 24 | :returns: List of users. 25 | :rtype: list of dict 26 | """ 27 | return self.client.requests("users/", **kwargs) 28 | 29 | def get(self, **kwargs): 30 | """Return user with id = id. 31 | 32 | :param int id: The user id 33 | 34 | :returns: User information 35 | :rtype: dict 36 | """ 37 | id = kwargs.pop("id") 38 | 39 | return self.client.requests("users/" + str(id)) 40 | 41 | def update(self, **kwargs): 42 | """Update the record of a user with id = user_id 43 | 44 | All user parameters that are not listed here must be 45 | changed via the UI. 46 | 47 | :param int id: The user id 48 | :param str first_name: First name of the user 49 | :param str last_name: Last name of the user 50 | 51 | :returns: User information 52 | :rtype: dict 53 | """ 54 | id = kwargs.pop("id") 55 | 56 | return self.client.requests("users/" + str(id), verb=PUT, **kwargs) 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | *.DS_Store 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /tests/test_orgs.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | 4 | 5 | ORGANIZATION = { 6 | "name": "Organization 1", 7 | "id": 1, 8 | "description": "", 9 | "created_on": "2023-11-16T00:00:00.000000+00:00", 10 | "sandbox": "false", 11 | "members": [], 12 | "devices": [], 13 | "networks": [], 14 | } 15 | 16 | 17 | @responses.activate 18 | def test_organizations_list(): 19 | responses.add(responses.GET,"https://localhost/device-api/v1/orgs/", 20 | status=200, 21 | json={ 22 | "meta": { 23 | "first_url": "https://localhost/device-api/v1/orgs/?page=1&per_page=2", 24 | "last_url": "https://localhost/device-api/v1/orgs/?page=2&per_page=2", 25 | "next_url": "https://localhost/device-api/v1/orgs/?page=2&per_page=2", 26 | "page": 1, 27 | "pages": 1, 28 | "per_page": 2, 29 | "prev_url": None, 30 | "total": 2 31 | }, 32 | "data": [ORGANIZATION], 33 | } 34 | ) 35 | 36 | client = quantaq.client.APIClient( 37 | "https://localhost/device-api/", 38 | api_key="a123", version="v1") 39 | 40 | resp = client.organizations.list() 41 | 42 | assert type(resp) == list 43 | assert type(resp[0]) == dict 44 | assert len(resp) == 1 45 | 46 | 47 | @responses.activate 48 | def test_organizations_get(): 49 | responses.add(responses.GET, "https://localhost/device-api/v1/orgs/1", 50 | status=200, 51 | json=ORGANIZATION, 52 | ) 53 | 54 | client = quantaq.client.APIClient( 55 | "https://localhost/device-api/", 56 | api_key="a123", version="v1") 57 | 58 | # test the GET verb 59 | resp = client.organizations.get(id=1) 60 | 61 | assert type(resp) == dict 62 | -------------------------------------------------------------------------------- /tests/test_networks.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | 4 | 5 | NETWORK = { 6 | "name": "Network 1", 7 | "id": 1, 8 | "description": "", 9 | "created_on": "2023-11-16T00:00:00.000000+00:00", 10 | "organization": 1, 11 | "members": [], 12 | "devices": [], 13 | } 14 | 15 | 16 | @responses.activate 17 | def test_networks_list(): 18 | responses.add(responses.GET,"https://localhost/device-api/v1/orgs/1/networks/", 19 | status=200, 20 | json={ 21 | "meta": { 22 | "first_url": "https://localhost/device-api/v1/orgs/1/networks/?page=1&per_page=2", 23 | "last_url": "https://localhost/device-api/v1/orgs/1/networks/?page=2&per_page=2", 24 | "next_url": "https://localhost/device-api/v1/orgs/1/networks/?page=2&per_page=2", 25 | "page": 1, 26 | "pages": 1, 27 | "per_page": 2, 28 | "prev_url": None, 29 | "total": 2 30 | }, 31 | "data": [NETWORK], 32 | } 33 | ) 34 | 35 | client = quantaq.client.APIClient( 36 | "https://localhost/device-api/", 37 | api_key="a123", version="v1") 38 | 39 | resp = client.networks.list(organization_id=1) 40 | 41 | assert type(resp) == list 42 | assert type(resp[0]) == dict 43 | assert len(resp) == 1 44 | 45 | 46 | @responses.activate 47 | def test_networks_get(): 48 | responses.add(responses.GET, "https://localhost/device-api/v1/orgs/1/networks/1", 49 | status=200, 50 | json=NETWORK, 51 | ) 52 | 53 | client = quantaq.client.APIClient( 54 | "https://localhost/device-api/", 55 | api_key="a123", version="v1") 56 | 57 | # test the GET verb 58 | resp = client.networks.get(organization_id=1, network_id=1) 59 | 60 | assert type(resp) == dict 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/py-quantaq.svg)](https://badge.fury.io/py/py-quantaq) 2 | ![run and build](https://github.com/quant-aq/py-quantaq/workflows/run%20and%20build/badge.svg) 3 | [![codecov](https://codecov.io/gh/quant-aq/py-quantaq/branch/master/graph/badge.svg)](https://codecov.io/gh/quant-aq/py-quantaq) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | 6 | # py-quantaq 7 | A python wrapper for the QuantAQ RESTful API 8 | 9 | ## Installation 10 | 11 | Install directly from PyPI: 12 | 13 | ```sh 14 | $ pip install -U py-quantaq 15 | ``` 16 | 17 | Or, install the library directly from GitHub: 18 | 19 | ```bash 20 | $ pip install git+https://github.com/quant-aq/py-quantaq.git 21 | ``` 22 | 23 | ## Docs 24 | 25 | Documentation is in progress, but can be found [here](https://quant-aq.github.io/py-quantaq). 26 | 27 | ## Authentication 28 | 29 | To use the API, you must first have an API key. You can obtain an API key from the [user dashboard][1]. Once you create a new API key, make sure to keep it secret! The easiest way to do this is to save your key as an environment variable. This process is unique to each OS, but many tutorials exist online. For Mac, do the following: 30 | 31 | Using your editor of choice, open up your `.bash_profile`: 32 | ```bash 33 | # open up your bash profile 34 | $ nano ~/.bash_profile 35 | ``` 36 | 37 | Next, save the API key as an environment variable: 38 | ```bash 39 | # add a line with your API Key 40 | export QUANTAQ_APIKEY= 41 | ``` 42 | 43 | Finally, source your `.bash_profile`: 44 | 45 | ```sh 46 | $ source ~/.bash_profile 47 | ``` 48 | 49 | Now, you shouldn't ever have to touch this again or remember the key! 50 | 51 | ## Tests 52 | 53 | To run the unittests: 54 | 55 | ```sh 56 | $ poetry run pytest tests 57 | ``` 58 | 59 | or, with coverage 60 | 61 | ```sh 62 | $ poetry run pytest tests --cov=quantaq --cov-report term-missing -s 63 | ``` 64 | 65 | Tests are automagically run via github actions on each build. Results and coverage are tracked via Code Coverage which can be viewed by clicking on the badge above. 66 | 67 | 68 | [1]: https://www.quant-aq.com/api-keys -------------------------------------------------------------------------------- /quantaq/endpoints/logs/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | 8 | class Logs(Domain): 9 | """Initialize the Logs group of endpoints. 10 | 11 | :returns: Domain for Logs 12 | :rtype: quantaq.models.Logs 13 | """ 14 | def __init__(self, client) -> None: 15 | super(Logs, self).__init__(client) 16 | 17 | def list(self, **kwargs) -> list: 18 | """Return a list of logs for device with serial number sn. 19 | 20 | :param str sn: The device serial number 21 | :param str start: Start date for log retrieval 22 | :param str stop: End date for log retrieval 23 | :param str limit: Limit the number of results returned 24 | :param str sort: Sort the results by a specific attribute 25 | :param str filter: Filter the query 26 | :param int per_page: Define the number of results to return per page 27 | 28 | :returns: Logs 29 | :rtype: list of dict 30 | """ 31 | sn = kwargs.pop("sn") 32 | 33 | return self.client.requests("log/{}/".format(sn), **kwargs) 34 | 35 | def get(self, **kwargs) -> dict: 36 | """Return a single log. 37 | 38 | :param int id: The id of the log 39 | 40 | :returns: Log information 41 | :rtype: dict 42 | """ 43 | id = kwargs.pop("id") 44 | 45 | return self.client.requests("log/" + str(id)) 46 | 47 | def update(self, **kwargs) -> dict: 48 | """Update a log record. 49 | 50 | :param int id: The log id 51 | :param str level: One of ['INFO', 'WARNING', 'CRITICAL'] 52 | :param str message: The log message 53 | 54 | :returns: Log information 55 | :rtype: dict 56 | """ 57 | id = kwargs.pop("id") 58 | 59 | return self.client.requests("log/" + str(id), verb=PUT, **kwargs) 60 | 61 | def drop(self, **kwargs) -> dict: 62 | """Delete the log record. 63 | 64 | :param int id: The log id 65 | 66 | :returns: API call status 67 | :rytpe: dict 68 | """ 69 | id = kwargs.pop("id") 70 | 71 | return self.client.requests("log/" + str(id), verb=DELETE) -------------------------------------------------------------------------------- /tests/test_cellular.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | import os 4 | import sys 5 | import pandas as pd 6 | import pytest 7 | 8 | from quantaq.exceptions import QuantAQAPIException 9 | 10 | @responses.activate 11 | def test_cellular_list(): 12 | responses.add(responses.GET, "https://localhost/device-api/v1/meta-data/cell-data/SN000-000/", 13 | status=200, 14 | json={ 15 | "meta": { 16 | "first_url": "https://localhost/device-api/v1/meta-data/cell-data/SN000-000/?page=1&per_page=2", 17 | "last_url": "https://localhost/device-api/v1/meta-data/cell-data/SN000-000/?page=1&per_page=2", 18 | "next_url": None, 19 | "page": 1, 20 | "pages": 1, 21 | "per_page": 2, 22 | "prev_url": None, 23 | "total": 2 24 | }, 25 | "data": [ 26 | { 27 | "level": "INFO", 28 | "message": "test message", 29 | "millis": 1000, 30 | "sn": "SN000-000", 31 | "timestamp": "2020-06-27T23:07:53.185276", 32 | "url": "https://localhost/device-api/v1/log/SN000-000/1" 33 | } 34 | ], 35 | } 36 | ) 37 | 38 | client = quantaq.client.APIClient( 39 | "https://localhost/device-api/", 40 | api_key="a123", version="v1") 41 | 42 | # test the GET verb 43 | resp = client.cellular.list(sn="SN000-000") 44 | 45 | assert type(resp) == list 46 | assert type(resp[0]) == dict 47 | assert len(resp) == 1 48 | 49 | # check the utility functions 50 | from quantaq.utils import to_dataframe 51 | 52 | df = to_dataframe(resp, force_ts=False) 53 | assert isinstance(df, pd.DataFrame) is True 54 | 55 | df = to_dataframe(resp, force_ts=True) 56 | assert isinstance(df, pd.DataFrame) is True 57 | 58 | # force an error 59 | with pytest.raises(TypeError): 60 | to_dataframe(df.loc[0]) 61 | 62 | 63 | 64 | @responses.activate 65 | def test_cellular_drop(): 66 | responses.add(responses.DELETE, "https://localhost/device-api/v1/meta-data/cell-data/1", 67 | status=202, 68 | json={} 69 | ) 70 | 71 | client = quantaq.client.APIClient( 72 | "https://localhost/device-api/", 73 | api_key="a123", version="v1") 74 | 75 | # test the GET verb 76 | resp = client.cellular.drop(id=1) 77 | 78 | assert type(resp) == dict 79 | assert responses.calls[0].response.status_code == 202 80 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | sys.path.insert(0, os.path.abspath('__themes')) 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'py-quantaq' 21 | author = 'QuantAQ, Inc.' 22 | import time 23 | if time.strftime("%Y") != "2019": 24 | copyright = '2019-{}, {}'.format(time.strftime("%Y"), author) 25 | else: 26 | copyright = '{}, {}'.format(time.strftime("%Y"), author) 27 | 28 | 29 | # The full version, including alpha/beta/rc tags 30 | import toml 31 | f = toml.load("../pyproject.toml") 32 | version = f['tool']['poetry']['version'] 33 | release = version 34 | 35 | 36 | # -- General configuration --------------------------------------------------- 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.autosummary', 44 | 'sphinx.ext.intersphinx', 45 | "sphinx_rtd_theme", 46 | ] 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ['_templates'] 50 | 51 | # List of patterns, relative to source directory, that match files and 52 | # directories to ignore when looking for source files. 53 | # This pattern also affects html_static_path and html_extra_path. 54 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 55 | 56 | autosummary_generate = True 57 | 58 | # -- Options for HTML output ------------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_theme = "sphinx_rtd_theme" 64 | 65 | # Add any paths that contain custom static files (such as style sheets) here, 66 | # relative to this directory. They are copied after the builtin static files, 67 | # so a file named "default.css" will overwrite the builtin "default.css". 68 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. py-quantaq documentation master file, created by 2 | sphinx-quickstart on Tue Jun 23 12:57:39 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | py-quantaq 7 | =========== 8 | 9 | `py-quantaq` is built to make it easy to access the `QuantAQ `_ REST API using Python. 10 | The most recent versions of the library can be found on `PyPI `_ and 11 | support applications written using Python 3.6 and later. 12 | 13 | 14 | Install the Library 15 | =================== 16 | 17 | The easiest way to install the library is from PyPI using `pip`, a package manager for Python: 18 | 19 | 20 | .. code-block:: shell 21 | 22 | $ pip install [-U] py-quantaq 23 | 24 | 25 | Or, you can add the library to your project using `poetry`, an alternative python project 26 | management tool: 27 | 28 | .. code-block:: shell 29 | 30 | $ poetry add py-quantaq 31 | 32 | 33 | You can also clone the repository from `GitHub `_ 34 | locally and build from source using poetry: 35 | 36 | .. code-block:: shell 37 | 38 | $ git clone https://github.com/quant-aq/py-quantaq.git 39 | $ cd py-quantaq/ 40 | $ poetry install 41 | 42 | 43 | 44 | .. important:: 45 | 46 | Both the package you're referencing and any dependencies **must be 47 | installed**. 48 | 49 | 50 | Running Tests 51 | ============== 52 | 53 | Testing is done automagically with each new build. However, you can also run tests 54 | locally if you have cloned the library. To run tests locally using `pytest`: 55 | 56 | .. code-block:: shell 57 | 58 | $ poetry run pytest tests 59 | 60 | To run them with coverage: 61 | 62 | .. code-block:: shell 63 | 64 | $ poetry run pytest tests --cov=quantaq --cov-report term-missing 65 | 66 | 67 | Making Contributions 68 | ==================== 69 | 70 | All development takes place on GitHub. Please see the GitHub repository 71 | for instructions. 72 | 73 | 74 | Reporting Bugs and Other Issues 75 | =============================== 76 | 77 | Any bugs, issues, or questions can be reported using the GitHub Issues tracker. Please 78 | provide as much information as possible that will make it easier to solve/fix the problem. 79 | Useful information to include would be the operating system, python version, and version 80 | of the opcsim library as well as any dependencies. If there are issues with graphics, 81 | screenshots are very helpful! 82 | 83 | 84 | .. toctree:: 85 | :hidden: 86 | :maxdepth: 3 87 | :caption: Contents: 88 | 89 | Home 90 | usage 91 | api 92 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: quantaq 2 | 3 | API Reference 4 | ============= 5 | 6 | Clients 7 | ------- 8 | 9 | .. rubric:: API Client 10 | 11 | API clients form the basis for making API requests. Typically, you will use the QuantAQAPIClient 12 | which will connect you directly to the QuantAQ website. If using an enterprise version of 13 | the QuantAQ API and server, you can build your own API client following the instructions in the 14 | Usage section. 15 | 16 | .. autosummary:: 17 | :toctree: generated/ 18 | 19 | client.ClientBase 20 | client.APIClient 21 | client.DevelopmentAPIClient 22 | client.StagingAPIClient 23 | client.ProductionAPIClient 24 | QuantAQAPIClient 25 | 26 | API Endpoints 27 | ------------- 28 | 29 | The following is an extensive list of all available API endpoints. Please check 30 | the Usage section for instructions on how to use them. 31 | 32 | .. rubric:: Users Endpoints 33 | 34 | .. autosummary:: 35 | :toctree: generated/ 36 | 37 | endpoints.users.Users.list 38 | endpoints.users.Users.get 39 | endpoints.users.Users.update 40 | 41 | 42 | .. rubric:: Organizations Endpoints 43 | 44 | .. autosummary:: 45 | :toctree: generated/ 46 | 47 | endpoints.organizations.Organizations.list 48 | endpoints.organizations.Organizations.get 49 | 50 | 51 | .. rubric:: Networks Endpoints 52 | 53 | .. autosummary:: 54 | :toctree: generated/ 55 | 56 | endpoints.networks.Networks.list 57 | endpoints.networks.Networks.get 58 | 59 | 60 | .. rubric:: Devices Endpoints 61 | 62 | .. autosummary:: 63 | :toctree: generated/ 64 | 65 | endpoints.devices.Devices.list 66 | endpoints.devices.Devices.get 67 | endpoints.devices.Devices.update 68 | endpoints.devices.Devices.add 69 | endpoints.devices.Devices.drop 70 | 71 | 72 | .. rubric:: Data Endpoints 73 | 74 | .. autosummary:: 75 | :toctree: generated/ 76 | 77 | endpoints.data.Data.list 78 | endpoints.data.Data.bydate 79 | endpoints.data.Data.get 80 | 81 | .. rubric:: Solar Power System Data Endpoints 82 | 83 | .. autosummary:: 84 | :toctree: generated/ 85 | 86 | endpoints.solar.Solar.list 87 | 88 | 89 | .. rubric:: Logs Endpoints 90 | 91 | .. autosummary:: 92 | :toctree: generated/ 93 | 94 | endpoints.logs.Logs.list 95 | endpoints.logs.Logs.get 96 | endpoints.logs.Logs.update 97 | endpoints.logs.Logs.drop 98 | 99 | 100 | .. rubric:: Cellular Logs Endpoints 101 | 102 | .. autosummary:: 103 | :toctree: generated/ 104 | 105 | endpoints.cellular.Cellular.list 106 | endpoints.cellular.Cellular.drop 107 | 108 | 109 | .. rubric:: Models Endpoints 110 | 111 | .. autosummary:: 112 | :toctree: generated/ 113 | 114 | endpoints.models.Models.add 115 | endpoints.models.Models.get 116 | 117 | 118 | Utilities 119 | --------- 120 | 121 | .. rubric:: Utils 122 | 123 | .. autosummary:: 124 | :toctree: generated/ 125 | 126 | utils.to_dataframe -------------------------------------------------------------------------------- /quantaq/endpoints/devices/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | class Devices(Domain): 8 | """Initialize the Devices group of endpoints. 9 | 10 | :returns: Domain for Devices 11 | :rtype: quantaq.models.Devices 12 | """ 13 | def __init__(self, client) -> None: 14 | super(Devices, self).__init__(client) 15 | 16 | def list(self, **kwargs) -> list: 17 | """Return all (available) devices. 18 | 19 | :param int organization_id: Filter to devices owned by given organization. 20 | :param int network_id: Filter to devices owned by given network. 21 | :param str limit: Limit the number of results returned 22 | :param str sort: Sort the results by a specific attribute 23 | :param str filter: Filter the query 24 | :param int per_page: Define the number of results to return per page 25 | 26 | :returns: List of devices. 27 | :rtype: list of dict 28 | """ 29 | # translate this to what's expected by the API 30 | if 'organization_id' in kwargs: 31 | kwargs["org_id"] = kwargs.pop("organization_id") 32 | return self.client.requests("devices/", **kwargs) 33 | 34 | def get(self, **kwargs) -> dict: 35 | """Return device with sn = sn. 36 | 37 | :param str sn: The device serial number 38 | 39 | :returns: Device information 40 | :rtype: dict 41 | """ 42 | sn = kwargs.pop("sn") 43 | 44 | return self.client.requests("devices/" + sn) 45 | 46 | def update(self, **kwargs) -> dict: 47 | """Update the record of a device with sn = sn 48 | 49 | :param str sn: The device serial number 50 | :param float lat: geo.latitude 51 | :param float lon: geo.longitude 52 | :param str city: 53 | :param str country: The ISO country code 54 | :param str description: 55 | :param bool is_outdoors: 56 | :param bool is_private: 57 | :param str device_state: 58 | :param str timezone: 59 | 60 | :returns: Device information 61 | :rtype: dict 62 | """ 63 | sn = kwargs.pop("sn") 64 | 65 | return self.client.requests("devices/" + sn, verb=PUT, **kwargs) 66 | 67 | def add(self, **kwargs) -> dict: 68 | """Add a new device. 69 | 70 | :param str sn: The device serial number 71 | :param str model: 72 | :param float lat: geo.latitude 73 | :param float lon: geo.longitude 74 | :param str city: 75 | :param str country: 76 | :param str description: 77 | :param bool is_outdoors: 78 | :param bool is_private: 79 | :param str device_state: 80 | :param str timezone: 81 | 82 | :returns: Device information 83 | :rtype: dict 84 | """ 85 | return self.client.requests("devices/", verb=POST, **kwargs) 86 | 87 | def drop(self, **kwargs) -> dict: 88 | """Delete a device. 89 | 90 | :param str sn: The device serial number 91 | 92 | :returns: status 93 | :rtype: dict 94 | """ 95 | sn = kwargs.pop("sn") 96 | return self.client.requests("devices/" + sn, verb=DELETE) -------------------------------------------------------------------------------- /tests/test_logs.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | import os 4 | import sys 5 | import pandas as pd 6 | import pytest 7 | 8 | from quantaq.exceptions import QuantAQAPIException 9 | 10 | @responses.activate 11 | def test_logs_list(): 12 | responses.add(responses.GET, "https://localhost/device-api/v1/log/SN000-000/", 13 | status=200, 14 | json={ 15 | "meta": { 16 | "first_url": "https://localhost/device-api/v1/log/SN000-000/?page=1&per_page=2", 17 | "last_url": "https://localhost/device-api/v1/log/SN000-000/?page=1&per_page=2", 18 | "next_url": None, 19 | "page": 1, 20 | "pages": 1, 21 | "per_page": 2, 22 | "prev_url": None, 23 | "total": 2 24 | }, 25 | "data": [ 26 | { 27 | "level": "INFO", 28 | "message": "test message", 29 | "millis": 1000, 30 | "sn": "SN000-000", 31 | "timestamp": "2020-06-27T23:07:53.185276", 32 | "url": "https://localhost/device-api/v1/log/SN000-000/1" 33 | } 34 | ], 35 | } 36 | ) 37 | 38 | client = quantaq.client.APIClient( 39 | "https://localhost/device-api/", 40 | api_key="a123", version="v1") 41 | 42 | # test the GET verb 43 | resp = client.logs.list(sn="SN000-000") 44 | 45 | assert type(resp) == list 46 | assert type(resp[0]) == dict 47 | assert 'millis' in resp[0] 48 | assert len(resp) == 1 49 | 50 | @responses.activate 51 | def test_logs_get(): 52 | responses.add(responses.GET, "https://localhost/device-api/v1/log/1", 53 | status=200, 54 | json={ 55 | "level": "INFO", 56 | "message": "test message", 57 | "millis": 1000, 58 | "sn": "SN000-000", 59 | "timestamp": "2020-06-27T23:07:53.185276", 60 | "url": "https://localhost/device-api/v1/log/SN000-000/1" 61 | } 62 | ) 63 | 64 | client = quantaq.client.APIClient( 65 | "https://localhost/device-api/", 66 | api_key="a123", version="v1") 67 | 68 | # test the GET verb 69 | resp = client.logs.get(id=1) 70 | 71 | assert type(resp) == dict 72 | assert 'millis' in resp 73 | 74 | @responses.activate 75 | def test_logs_update(): 76 | responses.add(responses.PUT, "https://localhost/device-api/v1/log/1", 77 | status=200, 78 | json={ 79 | "level": "WARNING", 80 | "message": "test message", 81 | "millis": 1000, 82 | "sn": "SN000-000", 83 | "timestamp": "2020-06-27T23:07:53.185276", 84 | "url": "https://localhost/device-api/v1/log/SN000-000/1" 85 | } 86 | ) 87 | 88 | client = quantaq.client.APIClient( 89 | "https://localhost/device-api/", 90 | api_key="a123", version="v1") 91 | 92 | # test the GET verb 93 | resp = client.logs.update(id=1, level="WARNING") 94 | 95 | assert type(resp) == dict 96 | assert 'millis' in resp 97 | assert resp['level'] == 'WARNING' 98 | 99 | 100 | @responses.activate 101 | def test_logs_delete(): 102 | responses.add(responses.DELETE, "https://localhost/device-api/v1/log/1", 103 | status=200, 104 | json={"log deleted": "success"}) 105 | 106 | client = quantaq.client.APIClient( 107 | "https://localhost/device-api/", 108 | api_key="a123", version="v1") 109 | 110 | # test the GET verb 111 | resp = client.logs.drop(id=1) 112 | 113 | assert type(resp) == dict 114 | -------------------------------------------------------------------------------- /quantaq/endpoints/data/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from quantaq.endpoints import ( 3 | Domain, 4 | GET, PUT, POST, DELETE 5 | ) 6 | 7 | 8 | class Data(Domain): 9 | """Initialize the Data group of endpoints. 10 | 11 | :returns: Domain for Data 12 | :rtype: quantaq.models.Data 13 | """ 14 | def __init__(self, client) -> None: 15 | super(Data, self).__init__(client) 16 | 17 | def list(self, **kwargs) -> list: 18 | """Return all data for device with serial number sn. 19 | 20 | :param str sn: The device serial number 21 | :param bool raw: Return the raw (not final), default is False 22 | :param str start: Start date for data retrieval 23 | :param str stop: End date for data retrieval 24 | :param str limit: Limit the number of results returned 25 | :param str sort: Sort the results by a specific attribute 26 | :param str filter: Filter the query 27 | :param int per_page: Define the number of results to return per page 28 | 29 | :returns: Data 30 | :rtype: list of dict 31 | """ 32 | sn = kwargs.pop("sn") 33 | raw = kwargs.pop("raw", False) 34 | endpoint = "devices/" + sn + "/data/" 35 | 36 | if raw: 37 | endpoint += "raw/" 38 | 39 | return self.client.requests(endpoint, **kwargs) 40 | 41 | def bydate(self, **kwargs) -> list: 42 | """Return all data for a device with serial number 43 | on date . 44 | 45 | :param str sn: The device serial number 46 | :param str date: The date to retrieve data for in YYYY-MM-DD format (all GMT). 47 | :param bool raw: Return the raw (not final), default is False 48 | 49 | :returns: Data 50 | :rtype: list of dicts 51 | """ 52 | sn = kwargs.pop("sn") 53 | date = kwargs.pop("date") 54 | raw = kwargs.pop("raw", False) 55 | 56 | endpoint = "devices/" + sn + "/data-by-date/" 57 | if raw: 58 | endpoint += "raw/" 59 | 60 | endpoint += date + "/" 61 | 62 | return self.client.requests(endpoint) 63 | 64 | def byinterval(self, **kwargs) -> list: 65 | """Return resampled data for a device with serial number between 66 | and using resample period . 67 | 68 | :param str sn: The device serial number 69 | :param str start_date: The start date to retrieve data for in YYYY-MM-DD format (all GMT) 70 | :param str end_date: The end date to retrieve data for in YYYY-MM-DD format (all GMT) 71 | :param str period: The resample period; one of ['15min', '1h', '8h', or '1d'] 72 | 73 | :returns: paginated list of resampled data 74 | :rtype: list of dicts 75 | 76 | """ 77 | sn = kwargs.pop("sn") 78 | start = kwargs.pop("start_date") 79 | end = kwargs.pop("end_date") 80 | period = kwargs.pop("period") 81 | 82 | endpoint = f"devices/{sn}/data/resampled/?start_date={start}&end_date={end}&period={period}" 83 | 84 | return self.client.requests(endpoint, **kwargs) 85 | 86 | def get(self, **kwargs) -> dict: 87 | """Return a single data point. 88 | 89 | :param str sn: The device serial number 90 | :param int id: The id of the data point 91 | :param bool raw: Return the raw (not final), default is False 92 | 93 | :returns: Data information 94 | :rtype: dict 95 | """ 96 | sn = kwargs.pop("sn") 97 | id = kwargs.pop("id") 98 | raw = kwargs.pop("raw", False) 99 | 100 | endpoint = "devices/" + sn + '/data/' 101 | if raw: 102 | endpoint += "raw/" 103 | endpoint += str(id) 104 | 105 | return self.client.requests(endpoint) 106 | -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | # import unittest 2 | import responses 3 | import quantaq 4 | import os 5 | import sys 6 | import pandas as pd 7 | import pytest 8 | 9 | from quantaq.exceptions import QuantAQAPIException 10 | 11 | # add additional tests using https://github.com/getsentry/responses 12 | 13 | @responses.activate 14 | def test_base(monkeypatch): 15 | responses.add(responses.GET, "https://api.quant-aq.com/device-api/v1/account", 16 | json={"status": "success"}, status=200) 17 | 18 | # setup the API 19 | token = "A124324" 20 | client = quantaq.client.APIClient("https://api.quant-aq.com/device-api/", api_key=token, version="v1") 21 | 22 | # make sure you can init the client 23 | assert client.api_key == token 24 | assert client.base_url == "https://api.quant-aq.com/device-api/" 25 | assert client.version == "v1" 26 | 27 | # make sure client init fails with no token 28 | monkeypatch.setenv("QUANTAQ_APIKEY", "") 29 | with pytest.raises(QuantAQAPIException): 30 | client = quantaq.client.APIClient("https://test.com") 31 | 32 | # make sure url building works 33 | assert client.url("test") == "https://api.quant-aq.com/device-api/v1/test" 34 | 35 | # test the development api client 36 | client = quantaq.client.DevelopmentAPIClient(api_key="development") 37 | assert client.api_key == "development" 38 | assert client.base_url == "http://localhost:5000/device-api/" 39 | assert client.version == "v1" 40 | 41 | # test the staging api client 42 | client = quantaq.client.StagingAPIClient(api_key="staging") 43 | assert client.api_key == "staging" 44 | assert client.base_url == "https://api.quant-aq.dev/device-api/" 45 | assert client.version == "v1" 46 | 47 | # test the production api client 48 | client = quantaq.client.ProductionAPIClient(api_key="prod") 49 | assert client.api_key == "prod" 50 | assert client.base_url == "https://api.quant-aq.com/device-api/" 51 | assert client.version == "v1" 52 | 53 | 54 | @responses.activate 55 | def test_rate_limit_retry(): 56 | def set_responses(num_fails): 57 | responses.reset() 58 | # succeed first page, fail next one x times, succeed second page, succeed last one. 59 | responses.add( 60 | responses.GET, 61 | "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1", 62 | json={ 63 | "meta": { 64 | "first_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=1", 65 | "last_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=3", 66 | "next_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=2", 67 | "page": 1, 68 | "pages": 3, 69 | "per_page": 1, 70 | "prev_url": None, 71 | "total": 3, 72 | }, 73 | "data": [{"blah": 1}], 74 | }, 75 | status=200, 76 | ) 77 | for n in range(num_fails): 78 | responses.add( 79 | responses.GET, 80 | "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=2", 81 | json={"error": "too many requests", "message": "blah blah"}, 82 | status=429, 83 | ) 84 | responses.add( 85 | responses.GET, 86 | "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=2", 87 | json={ 88 | "meta": { 89 | "first_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=1", 90 | "last_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=3", 91 | "next_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=3", 92 | "page": 2, 93 | "pages": 3, 94 | "per_page": 1, 95 | "prev_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=1", 96 | "total": 3, 97 | }, 98 | "data": [{"blah": 2}], 99 | }, 100 | status=200, 101 | ) 102 | responses.add( 103 | responses.GET, 104 | "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=3", 105 | json={ 106 | "meta": { 107 | "first_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=1", 108 | "last_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=3", 109 | "next_url": None, 110 | "page": 3, 111 | "pages": 3, 112 | "per_page": 1, 113 | "prev_url": "https://localhost/device-api/v1/devices/BLAH/data/?per_page=1&page=2", 114 | "total": 3, 115 | }, 116 | "data": [{"blah": 3}], 117 | }, 118 | status=200, 119 | ) 120 | 121 | client = quantaq.client.APIClient( 122 | "https://localhost/device-api/", 123 | api_key="a123", 124 | version="v1", 125 | rate_limit_sleep_s=0, # don't wait in tests 126 | ) 127 | 128 | # if it fails twice, we're good 129 | set_responses(num_fails=2) 130 | resp = client.data.list(sn="BLAH", per_page=1) 131 | assert len(resp) == 3 132 | assert resp == [ 133 | {'blah': 1}, 134 | {'blah': 2}, 135 | {'blah': 3}, 136 | ] 137 | assert len(responses.calls) == 5 138 | 139 | # if it fails three times, that's surfaced 140 | set_responses(num_fails=3) 141 | with pytest.raises(QuantAQAPIException, match="Rate limiting retries exceeded."): 142 | client.data.list(sn="BLAH", per_page=1) 143 | assert len(responses.calls) == 4 144 | 145 | 146 | @responses.activate 147 | def test_whoami(): 148 | responses.add(responses.GET, "https://api.quant-aq.com/device-api/v1/account", 149 | status=200, 150 | json={ 151 | "confirmed": True, 152 | "email": "david@davidhhagan.com", 153 | "first_name": None, 154 | "id": 1, 155 | "is_administrator": True, 156 | "last_name": None, 157 | "last_seen": "2020-06-27T03:31:39.722291", 158 | "member_since": "2020-06-05T22:05:24.612347", 159 | "role": 5, 160 | "username": "david" 161 | } 162 | ) 163 | 164 | client = quantaq.client.APIClient( 165 | "https://api.quant-aq.com/device-api/", 166 | api_key="a123", version="v1") 167 | 168 | # test the GET verb 169 | resp = client.whoami() 170 | 171 | assert resp["confirmed"] == True 172 | assert type(resp) == dict 173 | assert len(responses.calls) == 1 174 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | import os 4 | import sys 5 | import pandas as pd 6 | import pytest 7 | 8 | from quantaq.exceptions import QuantAQAPIException 9 | 10 | @responses.activate 11 | def test_users_list(): 12 | responses.add(responses.GET, "https://localhost/device-api/v1/users/", 13 | status=200, 14 | json={ 15 | "meta": { 16 | "first_url": "https://localhost/device-api/v1/users/?page=1&per_page=2", 17 | "last_url": "https://localhost/device-api/v1/users/?page=2&per_page=2", 18 | "next_url": "https://localhost/device-api/v1/users/?page=2&per_page=2", 19 | "page": 1, 20 | "pages": 1, 21 | "per_page": 2, 22 | "prev_url": None, 23 | "total": 2 24 | }, 25 | "data": [ 26 | { 27 | "confirmed": True, 28 | "email": "david.hagan@quant-aq.com", 29 | "first_name": None, 30 | "id": 2, 31 | "is_administrator": True, 32 | "last_name": None, 33 | "last_seen": "2020-06-05T22:05:24.744063", 34 | "member_since": "2020-06-05T22:05:24.744057", 35 | "role": 5, 36 | "username": "david.hagan" 37 | }, 38 | { 39 | "confirmed": True, 40 | "email": "eben.cross@quant-aq.com", 41 | "first_name": None, 42 | "id": 3, 43 | "is_administrator": True, 44 | "last_name": None, 45 | "last_seen": "2020-06-05T22:05:24.895573", 46 | "member_since": "2020-06-05T22:05:24.895568", 47 | "role": 5, 48 | "username": "eben.cross" 49 | } 50 | ], 51 | } 52 | ) 53 | 54 | client = quantaq.client.APIClient( 55 | "https://localhost/device-api/", 56 | api_key="a123", version="v1") 57 | 58 | # test the GET verb 59 | resp = client.users.list() 60 | 61 | assert type(resp) == list 62 | assert type(resp[0]) == dict 63 | assert 'confirmed' in resp[0] 64 | assert len(resp) == 2 65 | 66 | @responses.activate 67 | def test_users_get(): 68 | responses.add(responses.GET, "https://localhost/device-api/v1/users/1", 69 | status=200, 70 | json={ 71 | "confirmed": True, 72 | "email": "david.hagan@quant-aq.com", 73 | "first_name": None, 74 | "id": 2, 75 | "is_administrator": True, 76 | "last_name": None, 77 | "last_seen": "2020-06-05T22:05:24.744063", 78 | "member_since": "2020-06-05T22:05:24.744057", 79 | "role": 5, 80 | "username": "david.hagan" 81 | }, 82 | ) 83 | 84 | client = quantaq.client.APIClient( 85 | "https://localhost/device-api/", 86 | api_key="a123", version="v1") 87 | 88 | # test the GET verb 89 | resp = client.users.get(id=1) 90 | 91 | assert type(resp) == dict 92 | 93 | @responses.activate 94 | def test_users_list_paginate(): 95 | responses.add(responses.GET, "https://localhost/device-api/v1/users/", 96 | status=200, json={ 97 | "data": [ 98 | { 99 | "confirmed": True, 100 | "email": "david.hagan@quant-aq.com", 101 | "first_name": None, 102 | "id": 2, 103 | "is_administrator": True, 104 | "last_name": None, 105 | "last_seen": "2020-06-05T22:05:24.744063", 106 | "member_since": "2020-06-05T22:05:24.744057", 107 | "role": 5, 108 | "username": "david.hagan" 109 | }, 110 | { 111 | "confirmed": True, 112 | "email": "eben.cross@quant-aq.com", 113 | "first_name": None, 114 | "id": 3, 115 | "is_administrator": True, 116 | "last_name": None, 117 | "last_seen": "2020-06-05T22:05:24.895573", 118 | "member_since": "2020-06-05T22:05:24.895568", 119 | "role": 5, 120 | "username": "eben.cross" 121 | } 122 | ], 123 | "meta": { 124 | "first_url": "https://localhost/device-api/v1/users/?page=1&per_page=2", 125 | "last_url": "https://localhost/device-api/v1/users/?page=2&per_page=2", 126 | "next_url": "https://localhost/device-api/v1/users/?page=2&per_page=2", 127 | "page": 1, 128 | "pages": 2, 129 | "per_page": 2, 130 | "prev_url": None, 131 | "total": 3 132 | } 133 | }) 134 | responses.add(responses.GET, "https://localhost/device-api/v1/users/", 135 | status=200, json={ 136 | "data": [ 137 | { 138 | "confirmed": True, 139 | "email": "david@davidhhagan.com", 140 | "first_name": None, 141 | "id": 1, 142 | "is_administrator": True, 143 | "last_name": None, 144 | "last_seen": "2020-06-27T14:07:48.808618", 145 | "member_since": "2020-06-05T22:05:24.612347", 146 | "role": 5, 147 | "username": "david" 148 | } 149 | ], 150 | "meta": { 151 | "first_url": "https://localhost/device-api/v1/users/?page=1&per_page=2", 152 | "last_url": "https://localhost/device-api/v1/users/?page=2&per_page=2", 153 | "next_url": None, 154 | "page": 2, 155 | "pages": 2, 156 | "per_page": 2, 157 | "prev_url": "https://localhost/device-api/v1/users/?page=1&per_page=2", 158 | "total": 3 159 | } 160 | }) 161 | 162 | # make sure there were two calls 163 | client = quantaq.client.APIClient( 164 | "https://localhost/device-api/", api_key="a123", 165 | version="v1") 166 | 167 | resp = client.users.list(per_page=2) 168 | 169 | assert len(resp) == 3 170 | assert len(responses.calls) == 2 171 | 172 | resp = client.users.list(per_page=1, limit=1, page=1) 173 | 174 | assert len(resp) == 1 175 | 176 | @responses.activate 177 | def test_users_update(): 178 | responses.add( 179 | responses.PUT, "https://localhost/device-api/v1/users/1", 180 | status=200, json={ 181 | "confirmed": True, 182 | "email": "david@davidhhagan.com", 183 | "first_name": None, 184 | "id": 1, 185 | "is_administrator": True, 186 | "last_name": "Hagan", 187 | "last_seen": "2020-06-27T14:30:29.106842", 188 | "member_since": "2020-06-05T22:05:24.612347", 189 | "role": 5, 190 | "username": "david" 191 | } 192 | ) 193 | 194 | # make sure there were two calls 195 | client = quantaq.client.APIClient( 196 | "https://localhost/device-api/", api_key="a123", 197 | version="v1") 198 | 199 | resp = client.users.update(id=1, last_name="Hagan") 200 | 201 | assert responses.calls[0].response.status_code == 200 202 | assert resp["last_name"] == "Hagan" -------------------------------------------------------------------------------- /tests/test_devices.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | import os 4 | import sys 5 | import pandas as pd 6 | import pytest 7 | 8 | from quantaq.exceptions import QuantAQAPIException 9 | 10 | @responses.activate 11 | def test_devices_list(): 12 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/", 13 | status=200, 14 | json={ 15 | "meta": { 16 | "first_url": "https://localhost/device-api/v1/devices/?page=1&per_page=2", 17 | "last_url": "https://localhost/device-api/v1/devices/?page=2&per_page=2", 18 | "next_url": "https://localhost/device-api/v1/devices/?page=2&per_page=2", 19 | "page": 1, 20 | "pages": 1, 21 | "per_page": 2, 22 | "prev_url": None, 23 | "total": 2 24 | }, 25 | "data": [ 26 | { 27 | "city": "Fort Collins", 28 | "country": "US", 29 | "created": "2020-06-08T15:38:17.274489", 30 | "description": None, 31 | "geo": { 32 | "lat": 40.9844188816344, 33 | "lon": 105.561207967016 34 | }, 35 | "id": 2, 36 | "last_seen": "2020-06-08T15:39:16.923105", 37 | "model": "arisense_v200", 38 | "n_datapoints": 10, 39 | "outdoors": True, 40 | "owner_id": None, 41 | "private": False, 42 | "sn": "SN000-001", 43 | "status": "ACTIVE", 44 | "timezone": "US/Mountain", 45 | "url": "https://localhost/device-api/v1/devices/SN000-001" 46 | }, 47 | { 48 | "city": "Fort Collins", 49 | "country": "US", 50 | "created": "2020-06-08T15:38:17.275411", 51 | "description": None, 52 | "geo": { 53 | "lat": 41.5456805631496, 54 | "lon": 105.795622755017 55 | }, 56 | "id": 3, 57 | "last_seen": "2020-06-08T15:39:17.154547", 58 | "model": "arisense_v200", 59 | "n_datapoints": 10, 60 | "outdoors": True, 61 | "owner_id": None, 62 | "private": False, 63 | "sn": "SN000-002", 64 | "status": "ACTIVE", 65 | "timezone": "US/Mountain", 66 | "url": "https://localhost/device-api/v1/devices/SN000-002" 67 | }, 68 | { 69 | "city": "Fort Collins", 70 | "country": "US", 71 | "created": "2020-06-08T15:38:17.265789", 72 | "description": "", 73 | "geo": { 74 | "lat": 40.6, 75 | "lon": 105.54 76 | }, 77 | "id": 1, 78 | "last_seen": "2020-06-08T15:40:01.174522", 79 | "model": "arisense_v200", 80 | "n_datapoints": 10, 81 | "outdoors": True, 82 | "owner_id": 1, 83 | "private": True, 84 | "sn": "SN000-000", 85 | "status": "ACTIVE", 86 | "timezone": "US/Mountain", 87 | "url": "https://localhost/device-api/v1/devices/SN000-000" 88 | } 89 | ], 90 | } 91 | ) 92 | 93 | client = quantaq.client.APIClient( 94 | "https://localhost/device-api/", 95 | api_key="a123", version="v1") 96 | 97 | # test the GET verb 98 | resp = client.devices.list() 99 | 100 | assert type(resp) == list 101 | assert type(resp[0]) == dict 102 | assert 'status' in resp[0] 103 | assert len(resp) == 3 104 | 105 | # brief check that organization_id/network_id are passed on as query parameters 106 | resp = client.devices.list(organization_id=1, network_id=1) 107 | assert 'network_id=1&org_id=1' in responses.calls[1].request.url 108 | assert type(resp) == list 109 | 110 | @responses.activate 111 | def test_devices_get(): 112 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/SN000-000", 113 | status=200, 114 | json={ 115 | "city": "Fort Collins", 116 | "country": "US", 117 | "created": "2020-06-08T15:38:17.265789", 118 | "description": "", 119 | "geo": { 120 | "lat": 40.6, 121 | "lon": 105.54 122 | }, 123 | "id": 1, 124 | "last_seen": "2020-06-08T15:40:01.174522", 125 | "model": "arisense_v200", 126 | "n_datapoints": 10, 127 | "outdoors": True, 128 | "owner_id": 1, 129 | "private": True, 130 | "sn": "SN000-000", 131 | "status": "ACTIVE", 132 | "timezone": "US/Mountain", 133 | "url": "https://localhost/device-api/v1/devices/SN000-000" 134 | }, 135 | ) 136 | 137 | client = quantaq.client.APIClient( 138 | "https://localhost/device-api/", 139 | api_key="a123", version="v1") 140 | 141 | # test the GET verb 142 | resp = client.devices.get(sn="SN000-000") 143 | 144 | assert type(resp) == dict 145 | 146 | @responses.activate 147 | def test_devices_update(): 148 | responses.add( 149 | responses.PUT, "https://localhost/device-api/v1/devices/SN000-000", 150 | status=200, json={ 151 | "city": "Fort Collins", 152 | "country": "UK", 153 | "created": "2020-06-08T15:38:17.265789", 154 | "description": "", 155 | "geo": { 156 | "lat": 40.6, 157 | "lon": 105.54 158 | }, 159 | "id": 1, 160 | "last_seen": "2020-06-08T15:40:01.174522", 161 | "model": "arisense_v200", 162 | "n_datapoints": 10, 163 | "outdoors": True, 164 | "owner_id": 1, 165 | "private": True, 166 | "sn": "SN000-000", 167 | "status": "ACTIVE", 168 | "timezone": "US/Mountain", 169 | "url": "https://localhost/device-api/v1/devices/SN000-000" 170 | } 171 | ) 172 | 173 | # make sure there were two calls 174 | client = quantaq.client.APIClient( 175 | "https://localhost/device-api/", api_key="a123", 176 | version="v1") 177 | 178 | resp = client.devices.update(sn="SN000-000", country="UK") 179 | 180 | assert responses.calls[0].response.status_code == 200 181 | assert resp["country"] == "UK" 182 | 183 | 184 | @responses.activate 185 | def test_devices_add(): 186 | responses.add( 187 | responses.POST, "https://localhost/device-api/v1/devices/", 188 | status=201, json={ 189 | "city": "Fort Collins", 190 | "country": "UK", 191 | "created": "2020-06-08T15:38:17.265789", 192 | "description": "", 193 | "geo": { 194 | "lat": 40.6, 195 | "lon": 105.54 196 | }, 197 | "id": 4, 198 | "last_seen": "2020-06-08T15:40:01.174522", 199 | "model": "arisense_v200", 200 | "n_datapoints": 10, 201 | "outdoors": True, 202 | "owner_id": 1, 203 | "private": True, 204 | "sn": "SN000-TMP", 205 | "status": "INACTIVE", 206 | "timezone": "US/Mountain", 207 | "url": "https://localhost/device-api/v1/devices/SN000-TMP" 208 | } 209 | ) 210 | 211 | # make sure there were two calls 212 | client = quantaq.client.APIClient( 213 | "https://localhost/device-api/", api_key="a123", 214 | version="v1") 215 | 216 | resp = client.devices.add( 217 | sn="SN000-000", 218 | country="UK", 219 | model="arisense_v200" 220 | ) 221 | 222 | assert responses.calls[0].response.status_code == 201 223 | assert resp["country"] == "UK" 224 | assert resp["model"] == "arisense_v200" 225 | 226 | 227 | @responses.activate 228 | def test_devices_drop(): 229 | responses.add( 230 | responses.DELETE, "https://localhost/device-api/v1/devices/SN000-TMP", 231 | status=202, json={"device dropped": "success"} 232 | ) 233 | 234 | # make sure there were two calls 235 | client = quantaq.client.APIClient( 236 | "https://localhost/device-api/", api_key="a123", 237 | version="v1") 238 | 239 | resp = client.devices.drop(sn="SN000-TMP") 240 | 241 | assert type(resp) == dict 242 | assert responses.calls[0].response.status_code == 202 -------------------------------------------------------------------------------- /quantaq/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import json 4 | import time 5 | import quantaq 6 | import requests 7 | import logging 8 | from requests.models import Response 9 | from typing import Optional, Union 10 | 11 | try: 12 | import urlparse 13 | except ImportError: 14 | from urllib import parse as urlparse 15 | 16 | from .exceptions import QuantAQAPIException 17 | 18 | GET = "GET" 19 | PUT = "PUT" 20 | DELETE = "DELETE" 21 | POST = "POST" 22 | 23 | class ClientBase(object): 24 | """A client for accessing the QuantAQ API.""" 25 | def __init__( 26 | self, 27 | api_key=None, 28 | base_url=None, 29 | version=None, 30 | rate_limit_retries=2, 31 | rate_limit_sleep_s=60, 32 | ) -> None: 33 | """ 34 | Initialize the QuantAQ API. 35 | 36 | :param str api_key: API key to authenticate with 37 | :param str base_url: The base url for API calls, defaults to 'https://api.quant-aq.com/device-api' 38 | :param str version: The API version, defaults to 'v1' 39 | :param int rate_limit_retries: Number of times to retry on 429 rate limit errors (default 2) 40 | :param int rate_limit_sleep_s: Seconds to sleep between rate limit retries (default 60) 41 | 42 | :returns: QuantAQ Client 43 | :rtype: quantaq.client.ClientBase 44 | """ 45 | # check for credentials 46 | self.api_key = api_key or os.environ.get("QUANTAQ_APIKEY", None) 47 | self.base_url = base_url 48 | self.version = version 49 | self.rate_limit_retries = rate_limit_retries 50 | self.rate_limit_sleep_s = rate_limit_sleep_s 51 | 52 | self._logger = logging.getLogger(__name__) 53 | 54 | self._users = None 55 | self._devices = None 56 | self._organizations = None 57 | self._networks = None 58 | self._data = None 59 | self._solar = None 60 | self._logs = None 61 | self._cellular = None 62 | self._models = None 63 | 64 | if not self.api_key: 65 | raise QuantAQAPIException("You must provide a valid API key") 66 | 67 | @property 68 | def headers(self): 69 | return { 70 | "Content-Type": "application/json" 71 | } 72 | 73 | @property 74 | def auth(self): 75 | return (self.api_key, "") 76 | 77 | def url(self, endpoint : str) -> str: 78 | """Build and return the url""" 79 | return urlparse.urljoin("{}{}/".format(self.base_url, self.version), endpoint) 80 | 81 | def paginate(self, endpoint, verb, params, data): 82 | """Iterate over all pages to get all of the data. If results aren't 83 | paginated, just return the results. 84 | 85 | :param endpoint str: 86 | :param verb str: the HTTP method 87 | :param params dict: 88 | :param data list: 89 | 90 | """ 91 | all_data = data.get("data") 92 | 93 | # iterate and get more data (if needed) 94 | while data.get("meta", dict()).get("next_url"): 95 | endpoint, q = data.get("meta").get("next_url").split("?", 1) 96 | 97 | for k, v in urlparse.parse_qs(q).items(): 98 | params[k] = v 99 | 100 | # re-issue the request for the next page 101 | # TODO should probably catch non-200 errors here. 102 | data = self.request(endpoint, verb, params).json() 103 | 104 | # append the data 105 | [all_data.append(item) for item in data.get('data')] 106 | 107 | return all_data 108 | 109 | def request(self, endpoint, verb=GET, params=None, **kwargs) -> Response: 110 | """Make a request to the QuantAQ API. 111 | 112 | :param str endpoint: Fully qualified URL 113 | :param str verb: HTTP method 114 | :param dict[str, str] params: Query string parameters 115 | 116 | :returns: Response from the QuantAQ API 117 | :rtype: requests.Response 118 | """ 119 | params = dict() if params is None else params 120 | 121 | # if certain kwargs are present, add them to params 122 | params = {**params, **kwargs} 123 | 124 | identity = lambda x: x 125 | json_dumps = lambda x: json.dumps(x) 126 | 127 | lookup = { 128 | GET: (requests.get, {}, "params", identity), 129 | POST: (requests.post, self.headers, "data", json_dumps), 130 | PUT: (requests.put, self.headers, "data", json_dumps), 131 | DELETE: (requests.delete, self.headers, "data", json_dumps) 132 | } 133 | 134 | requests_method, headers, payload, transform = lookup[verb] 135 | 136 | # create a logging string 137 | agent = "{0}/{1} {2}/{3}".format( 138 | quantaq.__name__, quantaq.__version__, 139 | requests.__name__, requests.__version__) 140 | 141 | # set the kwargs 142 | kwargs = {"headers": headers, payload: transform(params)} 143 | 144 | # build the url 145 | url = self.url(endpoint) 146 | 147 | # log the request 148 | self._logger.debug("{} {} {}: {} {}".format(type, url, payload, params, agent)) 149 | 150 | # retry up to {rate_limiting_retries} times on rate limit (429) 151 | for attempt in range(self.rate_limit_retries + 1): 152 | response = requests_method(url, auth=self.auth, **kwargs) 153 | 154 | if response.status_code != 429: 155 | return response 156 | 157 | if attempt < self.rate_limit_retries: 158 | self._logger.warning(f"Rate limited (429), sleeping {self.rate_limit_sleep_s}s before retry {attempt + 1}/{self.rate_limit_retries}") 159 | time.sleep(self.rate_limit_sleep_s) 160 | else: 161 | raise QuantAQAPIException("Rate limiting retries exceeded.") 162 | 163 | return response 164 | 165 | def requests(self, endpoint, verb=GET, params=None, **kwargs): 166 | """Request, but for many of them (i.e. deals with pagination) 167 | """ 168 | params = dict() if params is None else params 169 | 170 | # set defaults 171 | if verb == GET: 172 | params.setdefault("per_page", 100) 173 | 174 | # add start and end to the kwargs 175 | filter = kwargs.pop("filter", []) 176 | if filter != []: 177 | filter = filter.split(";") 178 | 179 | if "start" in kwargs.keys(): 180 | filter.append("timestamp,ge," + kwargs.pop("start")) 181 | 182 | if "stop" in kwargs.keys(): 183 | filter.append("timestamp,le," + kwargs.pop("stop")) 184 | 185 | # add filter to the kwargs 186 | if len(filter) > 0: 187 | kwargs = {**kwargs, **dict(filter=";".join(filter))} 188 | 189 | params = {**params, **kwargs} 190 | 191 | # make the request 192 | r = self.request(endpoint, verb, params) 193 | 194 | # check for errors 195 | if r.status_code not in (200, 201, 202): 196 | raise QuantAQAPIException("Bad response ({}): {}".format(r.status_code, r.json())) 197 | 198 | # get the json response 199 | try: 200 | data = r.json() 201 | except ValueError: 202 | raise QuantAQAPIException("Could not decode the json response") 203 | 204 | # deal with pagination if needed 205 | pages = data.get("meta", None) 206 | if pages: 207 | if pages.get("next_url") and pages.get("page") != pages.get("pages"): 208 | data = self.paginate(endpoint, verb, params, data) 209 | else: 210 | data = data.get('data') 211 | 212 | return data 213 | 214 | def __str__(self): #pragma: no cover 215 | return "<%s>" % self.__class__.__name__ 216 | 217 | 218 | class APIClient(ClientBase): 219 | """ 220 | """ 221 | def __init__(self, base_url, api_key=None, version=None, **kwargs): 222 | super(APIClient, self).__init__(api_key, base_url, version, **kwargs) 223 | 224 | def whoami(self): 225 | """Return information about the current account user 226 | 227 | Examples 228 | -------- 229 | 230 | >>> client = quantaq.QuantAQAPIClient() 231 | >>> client.whoami() 232 | 233 | """ 234 | return self.requests("account") 235 | 236 | @property 237 | def users(self): 238 | """""" 239 | if self._users is None: 240 | from .endpoints.users import Users 241 | self._users = Users(self) 242 | return self._users 243 | 244 | @property 245 | def organizations(self): 246 | """""" 247 | if self._organizations is None: 248 | from .endpoints.organizations import Organizations 249 | self._organizations = Organizations(self) 250 | return self._organizations 251 | 252 | @property 253 | def networks(self): 254 | """""" 255 | if self._networks is None: 256 | from .endpoints.networks import Networks 257 | self._networks = Networks(self) 258 | return self._networks 259 | 260 | @property 261 | def devices(self): 262 | """""" 263 | if self._devices is None: 264 | from .endpoints.devices import Devices 265 | self._devices = Devices(self) 266 | return self._devices 267 | 268 | @property 269 | def data(self): 270 | """""" 271 | if self._data is None: 272 | from .endpoints.data import Data 273 | self._data = Data(self) 274 | return self._data 275 | 276 | @property 277 | def solar(self): 278 | """""" 279 | if self._solar is None: 280 | from .endpoints.solar import Solar 281 | self._solar = Solar(self) 282 | return self._solar 283 | 284 | @property 285 | def logs(self): 286 | """""" 287 | if self._logs is None: 288 | from .endpoints.logs import Logs 289 | self._logs = Logs(self) 290 | return self._logs 291 | 292 | @property 293 | def cellular(self): 294 | """""" 295 | if self._cellular is None: 296 | from .endpoints.cellular import Cellular 297 | self._cellular = Cellular(self) 298 | return self._cellular 299 | 300 | @property 301 | def models(self): 302 | """""" 303 | if self._models is None: 304 | from .endpoints.models import Models 305 | self._models = Models(self) 306 | return self._models 307 | 308 | 309 | class DevelopmentAPIClient(APIClient): 310 | def __init__(self, api_key=None, **kwargs) -> None: 311 | super().__init__("http://localhost:5000/device-api/", 312 | version="v1", api_key=api_key, **kwargs) 313 | 314 | 315 | class StagingAPIClient(APIClient): 316 | def __init__(self, api_key=None, **kwargs) -> None: 317 | super().__init__("https://api.quant-aq.dev/device-api/", 318 | version="v1", api_key=api_key, **kwargs) 319 | 320 | 321 | class ProductionAPIClient(APIClient): 322 | def __init__(self, api_key=None, **kwargs) -> None: 323 | super().__init__("https://api.quant-aq.com/device-api/", 324 | version="v1", api_key=api_key, **kwargs) 325 | 326 | 327 | 328 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: sh 2 | 3 | Using this Library 4 | ================== 5 | 6 | Credentials 7 | ----------- 8 | 9 | To use the QuantAQ API, you must have an API token. This can be obtained 10 | only via the website when logged in to your account. This token should 11 | be stored as an environment variable called **QUANTAQ_APIKEY** in order 12 | to be automagically be discovered. You can also set it when you intiate 13 | a client as seen below. 14 | 15 | 16 | Setup and Authentication 17 | ------------------------- 18 | 19 | There are several clients that can be used, with :class:`quantaq.QuantAQAPIClient` 20 | being your best bet. You can also create your own from :class:`quantaq.client.APIClient` if 21 | you are using an enterprise server at a different domain. 22 | 23 | To initiate the client (assuming you have set your API key as an environment variable): 24 | 25 | .. code-block:: python 26 | 27 | >>> import quantaq 28 | >>> client = quantaq.QuantAQAPIClient() 29 | 30 | 31 | If you would like to set your API key at initialization: 32 | 33 | .. code-block:: python 34 | 35 | >>> client = quantaq.QuantAQAPIClient(api_key="***") 36 | 37 | 38 | If you did not properly set the API key, an exception will be raised. 39 | 40 | 41 | Customize the Client 42 | ^^^^^^^^^^^^^^^^^^^^ 43 | 44 | If you would like to customize the client to use a custom 45 | domain or API version, you can do so by creating a new class: 46 | 47 | .. code-block:: python 48 | 49 | from quantaq.client import APIClient 50 | 51 | class CustomAPIClient(APIClient): 52 | def __init__(self, api_key=None): 53 | super().__init__("https://custom.domain/api", version="v1", api_key=api_key) 54 | 55 | 56 | You can then use it just as you would the :class:`quantaq.QuantAQAPIClient` class: 57 | 58 | .. code-block:: python 59 | 60 | >>> client = CustomAPIClient() 61 | 62 | 63 | Rate Limiting 64 | ^^^^^^^^^^^^^ 65 | 66 | The library by itself handles rate limiting that may be imposed by the QuantAQ API. 67 | When a response has a status code of 429, the client will retry up to 2 times, 68 | sleeping 60 seconds between each retry. 69 | 70 | This is an intentional, sensible default for QuantAQ's rate limiting policy, 71 | but can be customized by overriding the `rate_limit_retries` and/or `rate_limit_sleep_s` 72 | keyword arguments to the client constructor: 73 | 74 | .. code-block:: python 75 | 76 | >>> client = quantaq.QuantAQAPIClient(rate_limit_retries=3, rate_limit_sleep_s=120) 77 | 78 | 79 | Account Information 80 | -------------------- 81 | 82 | The API client offers easy access to all available QuantAQ API endpoints. To 83 | obtain your user account information, you can use the `whoami` method: 84 | 85 | .. code-block:: python 86 | 87 | >>> whoami = client.whoami() 88 | >>> print (whoami) 89 | 90 | 91 | Organizations 92 | ------------- 93 | 94 | List All Organizations 95 | ^^^^^^^^^^^^^^^^^^^^^^ 96 | 97 | You can retrieve a list of all the organizations visible to you: 98 | 99 | .. code-block:: python 100 | 101 | >>> organizations = client.organizations.list() 102 | >>> print (organizations) 103 | 104 | Get a Single Organization 105 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 106 | 107 | To get information about a specific organization, you can use the `get` method 108 | with the id as an argument: 109 | 110 | .. code-block:: python 111 | 112 | >>> organization = client.organizations.get(id=1) 113 | >>> print (organization) 114 | 115 | 116 | Networks 117 | -------- 118 | 119 | List All Networks 120 | ^^^^^^^^^^^^^^^^^ 121 | 122 | You can retrieve a list of all the networks visible to you, in the context of 123 | a given organization, with the organization_id as an argument: 124 | 125 | .. code-block:: python 126 | 127 | >>> networks = client.networks.list(organization_id=1) 128 | >>> print (networks) 129 | 130 | Get a Single Network 131 | ^^^^^^^^^^^^^^^^^^^^ 132 | 133 | To get information about a specific network, you can use the `get` method 134 | with the parent organization_id and the network_id as arguments: 135 | 136 | .. code-block:: python 137 | 138 | >>> network = client.networks.get(organization_id=1, network_id=1) 139 | >>> print (network) 140 | 141 | 142 | Devices 143 | -------- 144 | 145 | List All Devices 146 | ^^^^^^^^^^^^^^^^ 147 | 148 | To get a list of all devices: 149 | 150 | .. code-block:: python 151 | 152 | >>> devices = client.devices.list() 153 | >>> print (devices) 154 | 155 | Get a Single Device 156 | ^^^^^^^^^^^^^^^^^^^ 157 | 158 | You can also use the :meth:`quantaq.utils.to_dataframe` utility 159 | function to convert the list to a dataframe: 160 | 161 | .. code-block:: python 162 | 163 | >>> from quantaq.utils import to_dataframe 164 | >>> devices = to_dataframe(client.devices.list()) 165 | >>> print (devices) 166 | 167 | Devices - Advanced Queries 168 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 169 | 170 | Devices are filterable by organization and network, using the organization_id and network_id 171 | kwargs. For example, to get the devices in a particular organization: 172 | 173 | .. code-block:: python 174 | 175 | >>> devices = client.devices.list(organization_id=1) 176 | >>> print (devices) 177 | 178 | 179 | You can also limit the number of devices to return using the `limit` kwarg or 180 | apply advanced filters using the `filter` kwarg. More details on how to 181 | generate advanced queries can be found in the **Advanced Queries** section 182 | near the bottom of this document. However, here are a few examples: 183 | 184 | Return only the first ten devices: 185 | 186 | .. code-block:: python 187 | 188 | >>> devices = client.devices.list(limit=10) 189 | >>> print (devices) 190 | 191 | 192 | Return only devices with **device_state=ACTIVE**: 193 | 194 | .. code-block:: python 195 | 196 | >>> devices = client.devices.list(filter="device_state,eq,ACTIVE") 197 | >>> print (devices) 198 | 199 | 200 | Data 201 | ---- 202 | 203 | Data on the QuantAQ platform is described as either **final** 204 | data (e.g. PM1, PM2.5, CO, etc) which is cleaned, QA/QC'd, and 205 | ready to analyze, as well as **raw** data (e.g., voltages, raw bin 206 | counts for particle counters) which is only available to researchers and 207 | devices that you are allowed to view. If you have more questions about 208 | who can see what data, please feel free to reach out to us. 209 | 210 | List All Final Data for a Device 211 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 212 | 213 | You can only retrieve data for a specific device and must reference 214 | it by its serial number (`sn`). For example, we can retrieve the data 215 | between two dates: 216 | 217 | .. code-block:: python 218 | 219 | >>> from quantaq.utils import to_dataframe 220 | >>> data = client.data.list(sn="SN000-000", start="2020-01-01 00:00", stop="2020-01-01 03:30") 221 | >>> data = to_dataframe(data) 222 | >>> print (data) 223 | 224 | 225 | While you don't necessarily have to define either a start or stop point, it is highly 226 | recommended. If you don't, the response can take some time as it is iterating through 227 | a large number of API requests to retrieve the paginated results. 228 | 229 | .. tip:: 230 | 231 | It is best to use this endpoint for querying less than one day of data. If trying to return 232 | large chunks of data, use the `bydate` function detailed below. 233 | 234 | 235 | List All Raw Data for a Device 236 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 237 | 238 | If you would like to retrieve the raw data, you need to specify that 239 | in your request: 240 | 241 | .. code-block:: python 242 | 243 | >>> data = client.data.list(sn="SN000-000", start="2020-01-01", stop="2020-01-03", raw=True) 244 | >>> print (data) 245 | 246 | 247 | Retrieve Large Chunks of Data for a Device 248 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 249 | 250 | .. attention:: 251 | 252 | This endpoint was added with release 1.1.0 on March 31st, 2022. 253 | 254 | 255 | To retrieve large chunks of data, it is best to use the `bydate` function. 256 | 257 | .. code-block:: python 258 | 259 | >>> data = client.data.bydate(sn='SN000-000', date='2022-01-01') 260 | >>> data = to_dataframe(data) 261 | >>> print (data) 262 | 263 | This will retrieve all available all available data for a given device on a given date. To 264 | get data for many dates, simply iterate over all of the dates: 265 | 266 | .. code-block:: python 267 | 268 | >>> import pandas as pd 269 | >>> df = [] 270 | >>> for each in pd.date_range(start='2022-01-01', end='2022-01-15'): 271 | >>> df.append( 272 | >>> to_dataframe(client.data.bydate(sn='SN000-000', date=str(each.date()))) 273 | >>> ) 274 | >>> df = pd.concat(df) 275 | >>> print (df.info()) 276 | 277 | 278 | 279 | Limit Your Data Requests 280 | ^^^^^^^^^^^^^^^^^^^^^^^^ 281 | 282 | You can also limit your data requests so that you only return a 283 | limited number of data points: 284 | 285 | .. code-block:: python 286 | 287 | >>> data = client.data.list(sn="SN000-000", start="2020-01-01", limit=100) 288 | >>> print (data) 289 | 290 | 291 | Return the Most Recent Data 292 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 293 | 294 | You can combine filtering and limit to return just the most 295 | recent data point: 296 | 297 | .. code-block:: python 298 | 299 | >>> recent = client.data.list(sn="SN000-000", sort="timestamp,asc", limit=1) 300 | >>> print (recent) 301 | 302 | However, this is the default behaviour, so it is generally not necessary to add the 303 | sort. If you just return one data point, it will by default be the most recent. 304 | 305 | 306 | Advanced Data Requests 307 | ^^^^^^^^^^^^^^^^^^^^^^ 308 | 309 | You can also return just data that meets specific criteria. For example, 310 | if we want to return just data where carbon monoxide is between 200-1000 ppb: 311 | 312 | .. code-block:: python 313 | 314 | >>> data = client.data.list(sn="SN000-000", start="2020-01-01", filter="co,ge,200;co,le,1000") 315 | >>> print (data) 316 | 317 | Logs 318 | ---- 319 | 320 | List All Logs for a Device 321 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 322 | 323 | You can list all logs for a specific device using: 324 | 325 | .. code-block:: python 326 | 327 | >>> logs = client.logs.list(sn="SN000-000", limit=100) 328 | >>> print (logs) 329 | 330 | Like the data endpoint above, you can use the **start** and **stop** 331 | arguments to limit which logs to obtain. 332 | 333 | Get a Single Log 334 | ^^^^^^^^^^^^^^^^ 335 | 336 | You can obtain a single log by referencing its ID, which can 337 | be obtained from the list of logs above: 338 | 339 | .. code-block:: python 340 | 341 | >>> log = client.logs.get(id=111) 342 | >>> print (log) 343 | 344 | Update a Log 345 | ^^^^^^^^^^^^ 346 | 347 | You can update a log (if you have permissions to do so) by using 348 | the **update** method: 349 | 350 | .. code-block:: python 351 | 352 | >>> log = client.logs.update(id=111, message="", level="INFO") 353 | >>> print (log) 354 | 355 | Drop a Log 356 | ^^^^^^^^^^ 357 | 358 | You can also drop/delete a log if you have permissions: 359 | 360 | .. code-block:: python 361 | 362 | >>> result = client.logs.drop(id=111) 363 | >>> print (result) 364 | 365 | 366 | Cellular Logs 367 | ------------- 368 | 369 | Cellular logs are custom logs that contain detailed information about 370 | the state of the cellular or wireless connection of your devices. 371 | 372 | List All Cellular Logs 373 | ^^^^^^^^^^^^^^^^^^^^^^ 374 | 375 | You can list all cellular logs: 376 | 377 | .. code-block:: python 378 | 379 | >>> cell = client.cellular.list(sn="SN000-000", limit=100) 380 | >>> print (cell) 381 | 382 | Drop a Cellular Log 383 | ^^^^^^^^^^^^^^^^^^^ 384 | 385 | You can also drop/delete a cellular log (if you have permissions): 386 | 387 | .. code-block:: python 388 | 389 | >>> result = client.cellular.drop(id=1) 390 | >>> print (result) 391 | 392 | Models (ML Models) 393 | ------------------ 394 | 395 | Models summarize the machine learning models used to convert raw 396 | voltages and particle bin counts to the final data. While the model 397 | itself is not available, the summary statistics, error metrics, and 398 | a summary of the model can be retrieved. 399 | 400 | Get the Models for a Single Device 401 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 402 | 403 | To get the models used by a specific device: 404 | 405 | .. code-block:: python 406 | 407 | >>> models = client.models.list(sn="SN000-000") 408 | >>> print (models) 409 | 410 | 411 | Advanced Queries 412 | ---------------- 413 | 414 | The API itself has quite powerful querying capabilities that can be separated into 415 | three categories: filtering, limiting, and sorting responses. Below are a brief overview 416 | of each. 417 | 418 | Limiting Responses 419 | ^^^^^^^^^^^^^^^^^^ 420 | 421 | To limit the number of results that are returned for the endpoints that return many 422 | items, you can use the **limit** keyword argument. The only requirement is that it must 423 | be an integer (e.g., **limit=5**). 424 | 425 | As an example, if we want to return just the first 5 devices, we can add the limit keyword 426 | argument as follows: 427 | 428 | .. code-block:: python 429 | 430 | >>> devices = client.devices.list(limit=5) 431 | >>> print (len(devices)) 432 | 433 | Sorting Responses 434 | ^^^^^^^^^^^^^^^^^ 435 | 436 | You can sort the results returned using the **sort** keyword argument. To use, you must provide both 437 | a column to sort by as well as a sort instruction (either **asc** or **desc**). The final format 438 | looks like **sort=[column],[asc or desc]**. You can also join multiple sorts together using a semicolon. 439 | 440 | As an example, if we want to sort the list of devices by their serial number: 441 | 442 | .. code-block:: python 443 | 444 | >>> devices = client.devices.list(sort="sn,asc") 445 | 446 | 447 | This can be quite useful when combined with the limit function! For example, if we want to return the 448 | 100 highest CO values: 449 | 450 | .. code-block:: python 451 | 452 | >>> data = client.data.list(sn="SN000-000", sort="co,asc", limit=100) 453 | >>> print (data) 454 | 455 | 456 | Filtering 457 | ^^^^^^^^^ 458 | 459 | Filtering allows us to build incredibly detailed queries. There are several arguments that 460 | can be used to build queries including: 461 | 462 | * `eq`: equals 463 | * `ne`: not equals 464 | * `lt`: less than 465 | * `le`: less than or equal to 466 | * `gt`: greater than 467 | * `ge`: greater than or equal to 468 | * `in`: in 469 | * `like`: like 470 | 471 | The format of the argument must be `filter="[column],[arg],[value]"`. Like with sort, 472 | you can combine many filters together using a semicolon. 473 | 474 | For example, if we want to get all data where CO > 1000 ppb: 475 | 476 | .. code-block:: python 477 | 478 | >>> data = client.data.list(sn="SN000-000", filter="co,gt,1000") 479 | >>> print (data) 480 | 481 | 482 | If we want to grab the first 100 points where 1000 <= CO <= 5000 ppb: 483 | 484 | .. code-block:: python 485 | 486 | >>> data = client.data.list(sn="SN000-000", filter="co,ge,1000;co,le,5000", limit=100) 487 | >>> print (data) 488 | 489 | 490 | If you have more questions about how to build queries, feel free to add an issue to the 491 | GitHub repository. -------------------------------------------------------------------------------- /tests/test_data.py: -------------------------------------------------------------------------------- 1 | import responses 2 | import quantaq 3 | import os 4 | import sys 5 | import pandas as pd 6 | import pytest 7 | 8 | from quantaq.exceptions import QuantAQAPIException 9 | 10 | @responses.activate 11 | def test_data_list(): 12 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/SN000-000/data/", 13 | status=200, 14 | json={ 15 | "meta": { 16 | "first_url": "https://localhost/device-api/v1/devices/SN000-000/data/?page=1&per_page=2", 17 | "last_url": "https://localhost/device-api/v1/devices/SN000-000/data/?page=1&per_page=2", 18 | "next_url": None, 19 | "page": 1, 20 | "pages": 1, 21 | "per_page": 2, 22 | "prev_url": None, 23 | "total": 2 24 | }, 25 | "data": [ 26 | { 27 | "co": 214.535431338815, 28 | "co2": 480.390096687971, 29 | "geo": { 30 | "lat": 40.6000204324625, 31 | "lon": 105.541939714035 32 | }, 33 | "no": 10.200247314506, 34 | "no2": 44.6936656602402, 35 | "noise": 4.76532194833333, 36 | "o3": 0.442416260227818, 37 | "pm1": 5.47475036448307, 38 | "pm10": 5.94977449893884, 39 | "pm25": 5.6276302238071, 40 | "pressure": 100819.890261942, 41 | "rh_manifold": 6.62445621678516, 42 | "sn": "SN000-000", 43 | "solar": 2.49462009778851, 44 | "temp_box": 45.0578371327661, 45 | "temp_manifold": 33.3148097910394, 46 | "timestamp": "2020-06-08T15:30:16.669152", 47 | "timestamp_local": "2020-06-08T09:30:16.669152", 48 | "tvoc": None, 49 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/10", 50 | "wind_dir": 165.490010969395, 51 | "wind_speed": 55.9829228072949 52 | }, 53 | { 54 | "co": 790.176776778167, 55 | "co2": 479.654759364011, 56 | "geo": { 57 | "lat": 40.6000204324625, 58 | "lon": 105.541939714035 59 | }, 60 | "no": 24.7463880371725, 61 | "no2": 62.1976466739495, 62 | "noise": 8.49536012116147, 63 | "o3": 10.0045781706225, 64 | "pm1": 12.5814230319076, 65 | "pm10": 21.718901275421, 66 | "pm25": 14.2133728579415, 67 | "pressure": 101324.263617099, 68 | "rh_manifold": 81.8516598800883, 69 | "sn": "SN000-000", 70 | "solar": 7.80395850507548, 71 | "temp_box": 14.0545590060536, 72 | "temp_manifold": 0.36630906426483, 73 | "timestamp": "2020-06-08T15:31:16.649641", 74 | "timestamp_local": "2020-06-08T09:31:16.649641", 75 | "tvoc": None, 76 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/9", 77 | "wind_dir": 14.5751391589221, 78 | "wind_speed": 46.8812222542059 79 | } 80 | ], 81 | } 82 | ) 83 | 84 | client = quantaq.client.APIClient( 85 | "https://localhost/device-api/", 86 | api_key="a123", version="v1") 87 | 88 | # test the GET verb 89 | resp = client.data.list(sn="SN000-000") 90 | 91 | assert type(resp) == list 92 | assert type(resp[0]) == dict 93 | assert 'co' in resp[0] 94 | assert len(resp) == 2 95 | 96 | @responses.activate 97 | def test_data_list_params(): 98 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/SN000-000/data/", 99 | status=200, 100 | json={ 101 | "meta": { 102 | "first_url": "https://localhost/device-api/v1/devices/SN000-000/data/?page=1&per_page=2&filter=timestamp%2Clt%2C2020-06-15", 103 | "last_url": "https://localhost/device-api/v1/devices/SN000-000/data/?page=1&per_page=2&filter=timestamp%2Clt%2C2020-06-15", 104 | "next_url": None, 105 | "page": 1, 106 | "pages": 1, 107 | "per_page": 2, 108 | "prev_url": None, 109 | "total": 2 110 | }, 111 | "data": [ 112 | { 113 | "co": 214.535431338815, 114 | "co2": 480.390096687971, 115 | "geo": { 116 | "lat": 40.6000204324625, 117 | "lon": 105.541939714035 118 | }, 119 | "no": 10.200247314506, 120 | "no2": 44.6936656602402, 121 | "noise": 4.76532194833333, 122 | "o3": 0.442416260227818, 123 | "pm1": 5.47475036448307, 124 | "pm10": 5.94977449893884, 125 | "pm25": 5.6276302238071, 126 | "pressure": 100819.890261942, 127 | "rh_manifold": 6.62445621678516, 128 | "sn": "SN000-000", 129 | "solar": 2.49462009778851, 130 | "temp_box": 45.0578371327661, 131 | "temp_manifold": 33.3148097910394, 132 | "timestamp": "2020-06-08T15:30:16.669152", 133 | "timestamp_local": "2020-06-08T09:30:16.669152", 134 | "tvoc": None, 135 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/10", 136 | "wind_dir": 165.490010969395, 137 | "wind_speed": 55.9829228072949 138 | }, 139 | { 140 | "co": 790.176776778167, 141 | "co2": 479.654759364011, 142 | "geo": { 143 | "lat": 40.6000204324625, 144 | "lon": 105.541939714035 145 | }, 146 | "no": 24.7463880371725, 147 | "no2": 62.1976466739495, 148 | "noise": 8.49536012116147, 149 | "o3": 10.0045781706225, 150 | "pm1": 12.5814230319076, 151 | "pm10": 21.718901275421, 152 | "pm25": 14.2133728579415, 153 | "pressure": 101324.263617099, 154 | "rh_manifold": 81.8516598800883, 155 | "sn": "SN000-000", 156 | "solar": 7.80395850507548, 157 | "temp_box": 14.0545590060536, 158 | "temp_manifold": 0.36630906426483, 159 | "timestamp": "2020-06-08T15:31:16.649641", 160 | "timestamp_local": "2020-06-08T09:31:16.649641", 161 | "tvoc": None, 162 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/9", 163 | "wind_dir": 14.5751391589221, 164 | "wind_speed": 46.8812222542059 165 | } 166 | ], 167 | } 168 | ) 169 | 170 | client = quantaq.client.APIClient( 171 | "https://localhost/device-api/", 172 | api_key="a123", version="v1") 173 | 174 | # test the GET verb 175 | resp = client.data.list( 176 | sn="SN000-000", stop="2020-06-15", start="2020-01-01", 177 | filter="co,lt,10000") 178 | 179 | assert type(resp) == list 180 | assert type(resp[0]) == dict 181 | assert 'co' in resp[0] 182 | assert len(resp) == 2 183 | 184 | @responses.activate 185 | def test_data_list_raw(): 186 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/SN000-000/data/raw/", 187 | status=200, 188 | json={ 189 | "meta": { 190 | "first_url": "https://localhost/device-api/v1/devices/SN000-000/data/?page=1&per_page=2&filter=timestamp%2Clt%2C2020-06-15", 191 | "last_url": "https://localhost/device-api/v1/devices/SN000-000/data/?page=1&per_page=2&filter=timestamp%2Clt%2C2020-06-15", 192 | "next_url": None, 193 | "page": 1, 194 | "pages": 1, 195 | "per_page": 2, 196 | "prev_url": None, 197 | "total": 2 198 | }, 199 | "data": [ 200 | { 201 | "bin0": 226.981332635404, 202 | "bin1": 488.78728546421, 203 | "bin2": 268.937393606539, 204 | "bin3": 229.749245726731, 205 | "bin4": 356.54546564323, 206 | "bin5": 34.0198270215514, 207 | "co2_raw": 872.590957292812, 208 | "co_ae": 765.787103357745, 209 | "co_diff": None, 210 | "co_we": 685.691374109822, 211 | "dew_point": 7.711121605969, 212 | "flag": 2, 213 | "geo": { 214 | "lat": 40.6000204324625, 215 | "lon": 105.541939714035 216 | }, 217 | "no2_ae": 605.032546771419, 218 | "no2_diff": None, 219 | "no2_we": 411.322447975293, 220 | "no_ae": 659.05186710844, 221 | "no_diff": None, 222 | "no_we": 952.019991677906, 223 | "noise": 4.76532194833333, 224 | "o3_ae": 855.810520096843, 225 | "o3_diff": None, 226 | "o3_we": 579.253555954903, 227 | "opc_flow": 0.540979475271149, 228 | "opc_sample_time": None, 229 | "pressure": 100819.890261942, 230 | "rh_manifold": 6.62445621678516, 231 | "sn": "SN000-000", 232 | "solar": 2.49462009778851, 233 | "temp_box": 45.0578371327661, 234 | "temp_manifold": 33.3148097910394, 235 | "timestamp": "2020-06-08T15:30:16.669152", 236 | "timestamp_local": "2020-06-08T09:30:16.669152", 237 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/raw/10", 238 | "voc_raw": 430.325463304633, 239 | "wind_dir": 165.490010969395, 240 | "wind_speed": 55.9829228072949 241 | }, 242 | { 243 | "bin0": 622.608558774774, 244 | "bin1": 738.970400977273, 245 | "bin2": 91.1651905398073, 246 | "bin3": 364.858429084212, 247 | "bin4": 475.494741191533, 248 | "bin5": 393.100588436105, 249 | "co2_raw": 536.64224963332, 250 | "co_ae": 588.665943231794, 251 | "co_diff": None, 252 | "co_we": 298.240622433652, 253 | "dew_point": 4.48212742596445, 254 | "flag": 1, 255 | "geo": { 256 | "lat": 40.6000204324625, 257 | "lon": 105.541939714035 258 | }, 259 | "no2_ae": 332.351239629168, 260 | "no2_diff": None, 261 | "no2_we": 477.995557589061, 262 | "no_ae": 906.709183587395, 263 | "no_diff": None, 264 | "no_we": 415.876539828629, 265 | "noise": 8.49536012116147, 266 | "o3_ae": 485.318808232485, 267 | "o3_diff": None, 268 | "o3_we": 776.811635572823, 269 | "opc_flow": 0.206268002779296, 270 | "opc_sample_time": None, 271 | "pressure": 101324.263617099, 272 | "rh_manifold": 81.8516598800883, 273 | "sn": "SN000-000", 274 | "solar": 7.80395850507548, 275 | "temp_box": 14.0545590060536, 276 | "temp_manifold": 0.36630906426483, 277 | "timestamp": "2020-06-08T15:31:16.649641", 278 | "timestamp_local": "2020-06-08T09:31:16.649641", 279 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/raw/9", 280 | "voc_raw": 264.07539915575, 281 | "wind_dir": 14.5751391589221, 282 | "wind_speed": 46.8812222542059 283 | } 284 | ], 285 | } 286 | ) 287 | 288 | client = quantaq.client.APIClient( 289 | "https://localhost/device-api/", 290 | api_key="a123", version="v1") 291 | 292 | # test the GET verb 293 | resp = client.data.list( 294 | sn="SN000-000", stop="2020-06-15", start="2020-01-01", 295 | filter="co_we,lt,10000", raw=True) 296 | 297 | assert type(resp) == list 298 | assert type(resp[0]) == dict 299 | assert 'co_we' in resp[0] 300 | assert len(resp) == 2 301 | 302 | @responses.activate 303 | def test_data_get(): 304 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/SN000-000/data/10", 305 | status=200, json={ 306 | "co": 214.535431338815, 307 | "co2": 480.390096687971, 308 | "geo": { 309 | "lat": 40.6000204324625, 310 | "lon": 105.541939714035 311 | }, 312 | "no": 10.200247314506, 313 | "no2": 44.6936656602402, 314 | "noise": 4.76532194833333, 315 | "o3": 0.442416260227818, 316 | "pm1": 5.47475036448307, 317 | "pm10": 5.94977449893884, 318 | "pm25": 5.6276302238071, 319 | "pressure": 100819.890261942, 320 | "rh_manifold": 6.62445621678516, 321 | "sn": "SN000-000", 322 | "solar": 2.49462009778851, 323 | "temp_box": 45.0578371327661, 324 | "temp_manifold": 33.3148097910394, 325 | "timestamp": "2020-06-08T15:30:16.669152", 326 | "timestamp_local": "2020-06-08T09:30:16.669152", 327 | "tvoc": None, 328 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/10", 329 | "wind_dir": 165.490010969395, 330 | "wind_speed": 55.9829228072949 331 | }) 332 | 333 | client = quantaq.client.APIClient( 334 | "https://localhost/device-api/", 335 | api_key="a123", version="v1") 336 | 337 | # test the GET verb 338 | resp = client.data.get(sn="SN000-000", id=10) 339 | 340 | assert type(resp) == dict 341 | 342 | @responses.activate 343 | def test_data_get_raw(): 344 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/SN000-000/data/raw/10", 345 | status=200, json={ 346 | "co": 214.535431338815, 347 | "co2": 480.390096687971, 348 | "geo": { 349 | "lat": 40.6000204324625, 350 | "lon": 105.541939714035 351 | }, 352 | "no": 10.200247314506, 353 | "no2": 44.6936656602402, 354 | "noise": 4.76532194833333, 355 | "o3": 0.442416260227818, 356 | "pm1": 5.47475036448307, 357 | "pm10": 5.94977449893884, 358 | "pm25": 5.6276302238071, 359 | "pressure": 100819.890261942, 360 | "rh_manifold": 6.62445621678516, 361 | "sn": "SN000-000", 362 | "solar": 2.49462009778851, 363 | "temp_box": 45.0578371327661, 364 | "temp_manifold": 33.3148097910394, 365 | "timestamp": "2020-06-08T15:30:16.669152", 366 | "timestamp_local": "2020-06-08T09:30:16.669152", 367 | "tvoc": None, 368 | "url": "https://localhost/device-api/v1/devices/SN000-000/data/10", 369 | "wind_dir": 165.490010969395, 370 | "wind_speed": 55.9829228072949 371 | }) 372 | 373 | client = quantaq.client.APIClient( 374 | "https://localhost/device-api/", 375 | api_key="a123", version="v1") 376 | 377 | # test the GET verb 378 | resp = client.data.get(sn="SN000-000", id=10, raw=True) 379 | 380 | assert type(resp) == dict 381 | 382 | @responses.activate 383 | def test_resampled_data(): 384 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/MOD-00100/data/resampled/?start_date=2024-01-01&end_date=2024-01-02&period=1h&per_page=100", 385 | status=200, json={ 386 | "data": [ 387 | { 388 | "co": 1.234, 389 | }, 390 | ] 391 | } 392 | ) 393 | 394 | client = quantaq.client.APIClient( 395 | "https://localhost/device-api/", api_key="a123", version="v1" 396 | ) 397 | 398 | # Test the GET 399 | resp = client.data.byinterval(sn="MOD-00100", start_date="2024-01-01", end_date="2024-01-02", period="1h") 400 | 401 | assert type(resp) == dict 402 | 403 | @responses.activate 404 | def test_solar_data(): 405 | responses.add(responses.GET, "https://localhost/device-api/v1/devices/MOD-00100/data/solar/", 406 | status=200, json={ 407 | "data": [ 408 | 409 | ], 410 | "meta": { 411 | "total": 100, 412 | "per_page": 100 413 | } 414 | } 415 | ) 416 | 417 | client = quantaq.client.APIClient( 418 | "https://localhost/device-api/", api_key="a123", version="v1" 419 | ) 420 | 421 | resp = client.solar.list(sn="MOD-00100") 422 | 423 | assert type(resp) == list -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "alabaster" 5 | version = "0.7.13" 6 | description = "A configurable sidebar-enabled Sphinx theme" 7 | optional = false 8 | python-versions = ">=3.6" 9 | files = [ 10 | {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, 11 | {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, 12 | ] 13 | 14 | [[package]] 15 | name = "babel" 16 | version = "2.16.0" 17 | description = "Internationalization utilities" 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, 22 | {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, 23 | ] 24 | 25 | [package.dependencies] 26 | pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} 27 | 28 | [package.extras] 29 | dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] 30 | 31 | [[package]] 32 | name = "certifi" 33 | version = "2024.7.4" 34 | description = "Python package for providing Mozilla's CA Bundle." 35 | optional = false 36 | python-versions = ">=3.6" 37 | files = [ 38 | {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, 39 | {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, 40 | ] 41 | 42 | [[package]] 43 | name = "charset-normalizer" 44 | version = "3.3.2" 45 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 46 | optional = false 47 | python-versions = ">=3.7.0" 48 | files = [ 49 | {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, 50 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, 51 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, 52 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, 53 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, 54 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, 55 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, 56 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, 57 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, 58 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, 59 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, 60 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, 61 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, 62 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, 63 | {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, 64 | {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, 65 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, 66 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, 67 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, 68 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, 69 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, 70 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, 71 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, 72 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, 73 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, 74 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, 75 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, 76 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, 77 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, 78 | {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, 79 | {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, 80 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, 81 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, 82 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, 83 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, 84 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, 85 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, 86 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, 87 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, 88 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, 89 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, 90 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, 91 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, 92 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, 93 | {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, 94 | {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, 95 | {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, 96 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, 97 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, 98 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, 99 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, 100 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, 101 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, 102 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, 103 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, 104 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, 105 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, 106 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, 107 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, 108 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, 109 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, 110 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, 111 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, 112 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, 113 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, 114 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, 115 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, 116 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, 117 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, 118 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, 119 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, 120 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, 121 | {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, 122 | {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, 123 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, 124 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, 125 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, 126 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, 127 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, 128 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, 129 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, 130 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, 131 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, 132 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, 133 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, 134 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, 135 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, 136 | {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, 137 | {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, 138 | {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, 139 | ] 140 | 141 | [[package]] 142 | name = "colorama" 143 | version = "0.4.6" 144 | description = "Cross-platform colored terminal text." 145 | optional = false 146 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 147 | files = [ 148 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 149 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 150 | ] 151 | 152 | [[package]] 153 | name = "coverage" 154 | version = "7.6.1" 155 | description = "Code coverage measurement for Python" 156 | optional = false 157 | python-versions = ">=3.8" 158 | files = [ 159 | {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, 160 | {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, 161 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, 162 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, 163 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, 164 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, 165 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, 166 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, 167 | {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, 168 | {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, 169 | {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, 170 | {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, 171 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, 172 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, 173 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, 174 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, 175 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, 176 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, 177 | {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, 178 | {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, 179 | {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, 180 | {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, 181 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, 182 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, 183 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, 184 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, 185 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, 186 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, 187 | {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, 188 | {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, 189 | {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, 190 | {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, 191 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, 192 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, 193 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, 194 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, 195 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, 196 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, 197 | {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, 198 | {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, 199 | {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, 200 | {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, 201 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, 202 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, 203 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, 204 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, 205 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, 206 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, 207 | {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, 208 | {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, 209 | {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, 210 | {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, 211 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, 212 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, 213 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, 214 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, 215 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, 216 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, 217 | {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, 218 | {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, 219 | {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, 220 | {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, 221 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, 222 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, 223 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, 224 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, 225 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, 226 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, 227 | {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, 228 | {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, 229 | {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, 230 | {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, 231 | ] 232 | 233 | [package.dependencies] 234 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 235 | 236 | [package.extras] 237 | toml = ["tomli"] 238 | 239 | [[package]] 240 | name = "docutils" 241 | version = "0.16" 242 | description = "Docutils -- Python Documentation Utilities" 243 | optional = false 244 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 245 | files = [ 246 | {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, 247 | {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, 248 | ] 249 | 250 | [[package]] 251 | name = "exceptiongroup" 252 | version = "1.2.2" 253 | description = "Backport of PEP 654 (exception groups)" 254 | optional = false 255 | python-versions = ">=3.7" 256 | files = [ 257 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 258 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 259 | ] 260 | 261 | [package.extras] 262 | test = ["pytest (>=6)"] 263 | 264 | [[package]] 265 | name = "idna" 266 | version = "3.7" 267 | description = "Internationalized Domain Names in Applications (IDNA)" 268 | optional = false 269 | python-versions = ">=3.5" 270 | files = [ 271 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 272 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 273 | ] 274 | 275 | [[package]] 276 | name = "imagesize" 277 | version = "1.4.1" 278 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 279 | optional = false 280 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 281 | files = [ 282 | {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, 283 | {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, 284 | ] 285 | 286 | [[package]] 287 | name = "importlib-metadata" 288 | version = "8.4.0" 289 | description = "Read metadata from Python packages" 290 | optional = false 291 | python-versions = ">=3.8" 292 | files = [ 293 | {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, 294 | {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, 295 | ] 296 | 297 | [package.dependencies] 298 | zipp = ">=0.5" 299 | 300 | [package.extras] 301 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 302 | perf = ["ipython"] 303 | test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] 304 | 305 | [[package]] 306 | name = "iniconfig" 307 | version = "2.0.0" 308 | description = "brain-dead simple config-ini parsing" 309 | optional = false 310 | python-versions = ">=3.7" 311 | files = [ 312 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 313 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 314 | ] 315 | 316 | [[package]] 317 | name = "jinja2" 318 | version = "3.1.4" 319 | description = "A very fast and expressive template engine." 320 | optional = false 321 | python-versions = ">=3.7" 322 | files = [ 323 | {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, 324 | {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, 325 | ] 326 | 327 | [package.dependencies] 328 | MarkupSafe = ">=2.0" 329 | 330 | [package.extras] 331 | i18n = ["Babel (>=2.7)"] 332 | 333 | [[package]] 334 | name = "markupsafe" 335 | version = "2.1.5" 336 | description = "Safely add untrusted strings to HTML/XML markup." 337 | optional = false 338 | python-versions = ">=3.7" 339 | files = [ 340 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, 341 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, 342 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, 343 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, 344 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, 345 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, 346 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, 347 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, 348 | {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, 349 | {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, 350 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, 351 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, 352 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, 353 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, 354 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, 355 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, 356 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, 357 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, 358 | {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, 359 | {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, 360 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, 361 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, 362 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, 363 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, 364 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, 365 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, 366 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, 367 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, 368 | {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, 369 | {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, 370 | {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, 371 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, 372 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, 373 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, 374 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, 375 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, 376 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, 377 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, 378 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, 379 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, 380 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, 381 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, 382 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, 383 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, 384 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, 385 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, 386 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, 387 | {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, 388 | {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, 389 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, 390 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, 391 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, 392 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, 393 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, 394 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, 395 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, 396 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, 397 | {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, 398 | {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, 399 | {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, 400 | ] 401 | 402 | [[package]] 403 | name = "numpy" 404 | version = "1.24.4" 405 | description = "Fundamental package for array computing in Python" 406 | optional = false 407 | python-versions = ">=3.8" 408 | files = [ 409 | {file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"}, 410 | {file = "numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1"}, 411 | {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4"}, 412 | {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6"}, 413 | {file = "numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc"}, 414 | {file = "numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e"}, 415 | {file = "numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810"}, 416 | {file = "numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254"}, 417 | {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7"}, 418 | {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5"}, 419 | {file = "numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d"}, 420 | {file = "numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694"}, 421 | {file = "numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61"}, 422 | {file = "numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f"}, 423 | {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e"}, 424 | {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc"}, 425 | {file = "numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2"}, 426 | {file = "numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706"}, 427 | {file = "numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400"}, 428 | {file = "numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f"}, 429 | {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9"}, 430 | {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d"}, 431 | {file = "numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835"}, 432 | {file = "numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8"}, 433 | {file = "numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef"}, 434 | {file = "numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a"}, 435 | {file = "numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2"}, 436 | {file = "numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463"}, 437 | ] 438 | 439 | [[package]] 440 | name = "packaging" 441 | version = "24.1" 442 | description = "Core utilities for Python packages" 443 | optional = false 444 | python-versions = ">=3.8" 445 | files = [ 446 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 447 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 448 | ] 449 | 450 | [[package]] 451 | name = "pandas" 452 | version = "2.0.3" 453 | description = "Powerful data structures for data analysis, time series, and statistics" 454 | optional = false 455 | python-versions = ">=3.8" 456 | files = [ 457 | {file = "pandas-2.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c7c9f27a4185304c7caf96dc7d91bc60bc162221152de697c98eb0b2648dd8"}, 458 | {file = "pandas-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f167beed68918d62bffb6ec64f2e1d8a7d297a038f86d4aed056b9493fca407f"}, 459 | {file = "pandas-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce0c6f76a0f1ba361551f3e6dceaff06bde7514a374aa43e33b588ec10420183"}, 460 | {file = "pandas-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba619e410a21d8c387a1ea6e8a0e49bb42216474436245718d7f2e88a2f8d7c0"}, 461 | {file = "pandas-2.0.3-cp310-cp310-win32.whl", hash = "sha256:3ef285093b4fe5058eefd756100a367f27029913760773c8bf1d2d8bebe5d210"}, 462 | {file = "pandas-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:9ee1a69328d5c36c98d8e74db06f4ad518a1840e8ccb94a4ba86920986bb617e"}, 463 | {file = "pandas-2.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b084b91d8d66ab19f5bb3256cbd5ea661848338301940e17f4492b2ce0801fe8"}, 464 | {file = "pandas-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37673e3bdf1551b95bf5d4ce372b37770f9529743d2498032439371fc7b7eb26"}, 465 | {file = "pandas-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9cb1e14fdb546396b7e1b923ffaeeac24e4cedd14266c3497216dd4448e4f2d"}, 466 | {file = "pandas-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9cd88488cceb7635aebb84809d087468eb33551097d600c6dad13602029c2df"}, 467 | {file = "pandas-2.0.3-cp311-cp311-win32.whl", hash = "sha256:694888a81198786f0e164ee3a581df7d505024fbb1f15202fc7db88a71d84ebd"}, 468 | {file = "pandas-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6a21ab5c89dcbd57f78d0ae16630b090eec626360085a4148693def5452d8a6b"}, 469 | {file = "pandas-2.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9e4da0d45e7f34c069fe4d522359df7d23badf83abc1d1cef398895822d11061"}, 470 | {file = "pandas-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:32fca2ee1b0d93dd71d979726b12b61faa06aeb93cf77468776287f41ff8fdc5"}, 471 | {file = "pandas-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:258d3624b3ae734490e4d63c430256e716f488c4fcb7c8e9bde2d3aa46c29089"}, 472 | {file = "pandas-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eae3dc34fa1aa7772dd3fc60270d13ced7346fcbcfee017d3132ec625e23bb0"}, 473 | {file = "pandas-2.0.3-cp38-cp38-win32.whl", hash = "sha256:f3421a7afb1a43f7e38e82e844e2bca9a6d793d66c1a7f9f0ff39a795bbc5e02"}, 474 | {file = "pandas-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:69d7f3884c95da3a31ef82b7618af5710dba95bb885ffab339aad925c3e8ce78"}, 475 | {file = "pandas-2.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5247fb1ba347c1261cbbf0fcfba4a3121fbb4029d95d9ef4dc45406620b25c8b"}, 476 | {file = "pandas-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81af086f4543c9d8bb128328b5d32e9986e0c84d3ee673a2ac6fb57fd14f755e"}, 477 | {file = "pandas-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1994c789bf12a7c5098277fb43836ce090f1073858c10f9220998ac74f37c69b"}, 478 | {file = "pandas-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec591c48e29226bcbb316e0c1e9423622bc7a4eaf1ef7c3c9fa1a3981f89641"}, 479 | {file = "pandas-2.0.3-cp39-cp39-win32.whl", hash = "sha256:04dbdbaf2e4d46ca8da896e1805bc04eb85caa9a82e259e8eed00254d5e0c682"}, 480 | {file = "pandas-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:1168574b036cd8b93abc746171c9b4f1b83467438a5e45909fed645cf8692dbc"}, 481 | {file = "pandas-2.0.3.tar.gz", hash = "sha256:c02f372a88e0d17f36d3093a644c73cfc1788e876a7c4bcb4020a77512e2043c"}, 482 | ] 483 | 484 | [package.dependencies] 485 | numpy = [ 486 | {version = ">=1.20.3", markers = "python_version < \"3.10\""}, 487 | {version = ">=1.23.2", markers = "python_version >= \"3.11\""}, 488 | {version = ">=1.21.0", markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, 489 | ] 490 | python-dateutil = ">=2.8.2" 491 | pytz = ">=2020.1" 492 | tzdata = ">=2022.1" 493 | 494 | [package.extras] 495 | all = ["PyQt5 (>=5.15.1)", "SQLAlchemy (>=1.4.16)", "beautifulsoup4 (>=4.9.3)", "bottleneck (>=1.3.2)", "brotlipy (>=0.7.0)", "fastparquet (>=0.6.3)", "fsspec (>=2021.07.0)", "gcsfs (>=2021.07.0)", "html5lib (>=1.1)", "hypothesis (>=6.34.2)", "jinja2 (>=3.0.0)", "lxml (>=4.6.3)", "matplotlib (>=3.6.1)", "numba (>=0.53.1)", "numexpr (>=2.7.3)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.7)", "pandas-gbq (>=0.15.0)", "psycopg2 (>=2.8.6)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.2)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "python-snappy (>=0.6.0)", "pyxlsb (>=1.0.8)", "qtpy (>=2.2.0)", "s3fs (>=2021.08.0)", "scipy (>=1.7.1)", "tables (>=3.6.1)", "tabulate (>=0.8.9)", "xarray (>=0.21.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=1.4.3)", "zstandard (>=0.15.2)"] 496 | aws = ["s3fs (>=2021.08.0)"] 497 | clipboard = ["PyQt5 (>=5.15.1)", "qtpy (>=2.2.0)"] 498 | compression = ["brotlipy (>=0.7.0)", "python-snappy (>=0.6.0)", "zstandard (>=0.15.2)"] 499 | computation = ["scipy (>=1.7.1)", "xarray (>=0.21.0)"] 500 | excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.7)", "pyxlsb (>=1.0.8)", "xlrd (>=2.0.1)", "xlsxwriter (>=1.4.3)"] 501 | feather = ["pyarrow (>=7.0.0)"] 502 | fss = ["fsspec (>=2021.07.0)"] 503 | gcp = ["gcsfs (>=2021.07.0)", "pandas-gbq (>=0.15.0)"] 504 | hdf5 = ["tables (>=3.6.1)"] 505 | html = ["beautifulsoup4 (>=4.9.3)", "html5lib (>=1.1)", "lxml (>=4.6.3)"] 506 | mysql = ["SQLAlchemy (>=1.4.16)", "pymysql (>=1.0.2)"] 507 | output-formatting = ["jinja2 (>=3.0.0)", "tabulate (>=0.8.9)"] 508 | parquet = ["pyarrow (>=7.0.0)"] 509 | performance = ["bottleneck (>=1.3.2)", "numba (>=0.53.1)", "numexpr (>=2.7.1)"] 510 | plot = ["matplotlib (>=3.6.1)"] 511 | postgresql = ["SQLAlchemy (>=1.4.16)", "psycopg2 (>=2.8.6)"] 512 | spss = ["pyreadstat (>=1.1.2)"] 513 | sql-other = ["SQLAlchemy (>=1.4.16)"] 514 | test = ["hypothesis (>=6.34.2)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)"] 515 | xml = ["lxml (>=4.6.3)"] 516 | 517 | [[package]] 518 | name = "pluggy" 519 | version = "1.5.0" 520 | description = "plugin and hook calling mechanisms for python" 521 | optional = false 522 | python-versions = ">=3.8" 523 | files = [ 524 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 525 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 526 | ] 527 | 528 | [package.extras] 529 | dev = ["pre-commit", "tox"] 530 | testing = ["pytest", "pytest-benchmark"] 531 | 532 | [[package]] 533 | name = "pygments" 534 | version = "2.18.0" 535 | description = "Pygments is a syntax highlighting package written in Python." 536 | optional = false 537 | python-versions = ">=3.8" 538 | files = [ 539 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 540 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 541 | ] 542 | 543 | [package.extras] 544 | windows-terminal = ["colorama (>=0.4.6)"] 545 | 546 | [[package]] 547 | name = "pytest" 548 | version = "7.4.4" 549 | description = "pytest: simple powerful testing with Python" 550 | optional = false 551 | python-versions = ">=3.7" 552 | files = [ 553 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 554 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 555 | ] 556 | 557 | [package.dependencies] 558 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 559 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 560 | iniconfig = "*" 561 | packaging = "*" 562 | pluggy = ">=0.12,<2.0" 563 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 564 | 565 | [package.extras] 566 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 567 | 568 | [[package]] 569 | name = "pytest-cov" 570 | version = "5.0.0" 571 | description = "Pytest plugin for measuring coverage." 572 | optional = false 573 | python-versions = ">=3.8" 574 | files = [ 575 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 576 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 577 | ] 578 | 579 | [package.dependencies] 580 | coverage = {version = ">=5.2.1", extras = ["toml"]} 581 | pytest = ">=4.6" 582 | 583 | [package.extras] 584 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 585 | 586 | [[package]] 587 | name = "pytest-cover" 588 | version = "3.0.0" 589 | description = "Pytest plugin for measuring coverage. Forked from `pytest-cov`." 590 | optional = false 591 | python-versions = "*" 592 | files = [ 593 | {file = "pytest-cover-3.0.0.tar.gz", hash = "sha256:5bdb6c1cc3dd75583bb7bc2c57f5e1034a1bfcb79d27c71aceb0b16af981dbf4"}, 594 | {file = "pytest_cover-3.0.0-py2.py3-none-any.whl", hash = "sha256:578249955eb3b5f3991209df6e532bb770b647743b7392d3d97698dc02f39ebb"}, 595 | ] 596 | 597 | [package.dependencies] 598 | pytest-cov = ">=2.0" 599 | 600 | [[package]] 601 | name = "pytest-coverage" 602 | version = "0.0" 603 | description = "Pytest plugin for measuring coverage. Forked from `pytest-cov`." 604 | optional = false 605 | python-versions = "*" 606 | files = [ 607 | {file = "pytest-coverage-0.0.tar.gz", hash = "sha256:db6af2cbd7e458c7c9fd2b4207cee75258243c8a81cad31a7ee8cfad5be93c05"}, 608 | {file = "pytest_coverage-0.0-py2.py3-none-any.whl", hash = "sha256:dedd084c5e74d8e669355325916dc011539b190355021b037242514dee546368"}, 609 | ] 610 | 611 | [package.dependencies] 612 | pytest-cover = "*" 613 | 614 | [[package]] 615 | name = "python-dateutil" 616 | version = "2.9.0.post0" 617 | description = "Extensions to the standard Python datetime module" 618 | optional = false 619 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 620 | files = [ 621 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 622 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 623 | ] 624 | 625 | [package.dependencies] 626 | six = ">=1.5" 627 | 628 | [[package]] 629 | name = "pytz" 630 | version = "2024.1" 631 | description = "World timezone definitions, modern and historical" 632 | optional = false 633 | python-versions = "*" 634 | files = [ 635 | {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, 636 | {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, 637 | ] 638 | 639 | [[package]] 640 | name = "requests" 641 | version = "2.32.3" 642 | description = "Python HTTP for Humans." 643 | optional = false 644 | python-versions = ">=3.8" 645 | files = [ 646 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 647 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 648 | ] 649 | 650 | [package.dependencies] 651 | certifi = ">=2017.4.17" 652 | charset-normalizer = ">=2,<4" 653 | idna = ">=2.5,<4" 654 | urllib3 = ">=1.21.1,<3" 655 | 656 | [package.extras] 657 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 658 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 659 | 660 | [[package]] 661 | name = "responses" 662 | version = "0.10.16" 663 | description = "A utility library for mocking out the `requests` Python library." 664 | optional = false 665 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 666 | files = [ 667 | {file = "responses-0.10.16-py2.py3-none-any.whl", hash = "sha256:cf55b7c89fc77b9ebbc5e5924210b6d0ef437061b80f1273d7e202069e43493c"}, 668 | {file = "responses-0.10.16.tar.gz", hash = "sha256:fa125311607ab3e57d8fcc4da20587f041b4485bdfb06dd6bdf19d8b66f870c1"}, 669 | ] 670 | 671 | [package.dependencies] 672 | requests = ">=2.0" 673 | six = "*" 674 | urllib3 = ">=1.25.10" 675 | 676 | [package.extras] 677 | tests = ["coverage (>=3.7.1,<5.0.0)", "flake8", "pytest (>=4.6)", "pytest (>=4.6,<5.0)", "pytest-cov", "pytest-localserver"] 678 | 679 | [[package]] 680 | name = "setuptools" 681 | version = "75.3.2" 682 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 683 | optional = false 684 | python-versions = ">=3.8" 685 | files = [ 686 | {file = "setuptools-75.3.2-py3-none-any.whl", hash = "sha256:90ab613b6583fc02d5369cbca13ea26ea0e182d1df2d943ee9cbe81d4c61add9"}, 687 | {file = "setuptools-75.3.2.tar.gz", hash = "sha256:3c1383e1038b68556a382c1e8ded8887cd20141b0eb5708a6c8d277de49364f5"}, 688 | ] 689 | 690 | [package.extras] 691 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] 692 | core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] 693 | cover = ["pytest-cov"] 694 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 695 | enabler = ["pytest-enabler (>=2.2)"] 696 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "ruff (<=0.7.1)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 697 | type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] 698 | 699 | [[package]] 700 | name = "six" 701 | version = "1.16.0" 702 | description = "Python 2 and 3 compatibility utilities" 703 | optional = false 704 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 705 | files = [ 706 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 707 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 708 | ] 709 | 710 | [[package]] 711 | name = "snowballstemmer" 712 | version = "2.2.0" 713 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 714 | optional = false 715 | python-versions = "*" 716 | files = [ 717 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 718 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 719 | ] 720 | 721 | [[package]] 722 | name = "sphinx" 723 | version = "4.5.0" 724 | description = "Python documentation generator" 725 | optional = false 726 | python-versions = ">=3.6" 727 | files = [ 728 | {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, 729 | {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, 730 | ] 731 | 732 | [package.dependencies] 733 | alabaster = ">=0.7,<0.8" 734 | babel = ">=1.3" 735 | colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} 736 | docutils = ">=0.14,<0.18" 737 | imagesize = "*" 738 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 739 | Jinja2 = ">=2.3" 740 | packaging = "*" 741 | Pygments = ">=2.0" 742 | requests = ">=2.5.0" 743 | snowballstemmer = ">=1.1" 744 | sphinxcontrib-applehelp = "*" 745 | sphinxcontrib-devhelp = "*" 746 | sphinxcontrib-htmlhelp = ">=2.0.0" 747 | sphinxcontrib-jsmath = "*" 748 | sphinxcontrib-qthelp = "*" 749 | sphinxcontrib-serializinghtml = ">=1.1.5" 750 | 751 | [package.extras] 752 | docs = ["sphinxcontrib-websupport"] 753 | lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"] 754 | test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] 755 | 756 | [[package]] 757 | name = "sphinx-rtd-theme" 758 | version = "0.5.2" 759 | description = "Read the Docs theme for Sphinx" 760 | optional = false 761 | python-versions = "*" 762 | files = [ 763 | {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, 764 | {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, 765 | ] 766 | 767 | [package.dependencies] 768 | docutils = "<0.17" 769 | sphinx = "*" 770 | 771 | [package.extras] 772 | dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] 773 | 774 | [[package]] 775 | name = "sphinxcontrib-applehelp" 776 | version = "1.0.4" 777 | description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" 778 | optional = false 779 | python-versions = ">=3.8" 780 | files = [ 781 | {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, 782 | {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, 783 | ] 784 | 785 | [package.extras] 786 | lint = ["docutils-stubs", "flake8", "mypy"] 787 | test = ["pytest"] 788 | 789 | [[package]] 790 | name = "sphinxcontrib-devhelp" 791 | version = "1.0.2" 792 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." 793 | optional = false 794 | python-versions = ">=3.5" 795 | files = [ 796 | {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, 797 | {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, 798 | ] 799 | 800 | [package.extras] 801 | lint = ["docutils-stubs", "flake8", "mypy"] 802 | test = ["pytest"] 803 | 804 | [[package]] 805 | name = "sphinxcontrib-htmlhelp" 806 | version = "2.0.1" 807 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 808 | optional = false 809 | python-versions = ">=3.8" 810 | files = [ 811 | {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, 812 | {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, 813 | ] 814 | 815 | [package.extras] 816 | lint = ["docutils-stubs", "flake8", "mypy"] 817 | test = ["html5lib", "pytest"] 818 | 819 | [[package]] 820 | name = "sphinxcontrib-jsmath" 821 | version = "1.0.1" 822 | description = "A sphinx extension which renders display math in HTML via JavaScript" 823 | optional = false 824 | python-versions = ">=3.5" 825 | files = [ 826 | {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, 827 | {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, 828 | ] 829 | 830 | [package.extras] 831 | test = ["flake8", "mypy", "pytest"] 832 | 833 | [[package]] 834 | name = "sphinxcontrib-qthelp" 835 | version = "1.0.3" 836 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." 837 | optional = false 838 | python-versions = ">=3.5" 839 | files = [ 840 | {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, 841 | {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, 842 | ] 843 | 844 | [package.extras] 845 | lint = ["docutils-stubs", "flake8", "mypy"] 846 | test = ["pytest"] 847 | 848 | [[package]] 849 | name = "sphinxcontrib-serializinghtml" 850 | version = "1.1.5" 851 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." 852 | optional = false 853 | python-versions = ">=3.5" 854 | files = [ 855 | {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, 856 | {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, 857 | ] 858 | 859 | [package.extras] 860 | lint = ["docutils-stubs", "flake8", "mypy"] 861 | test = ["pytest"] 862 | 863 | [[package]] 864 | name = "toml" 865 | version = "0.10.2" 866 | description = "Python Library for Tom's Obvious, Minimal Language" 867 | optional = false 868 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 869 | files = [ 870 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 871 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 872 | ] 873 | 874 | [[package]] 875 | name = "tomli" 876 | version = "2.0.1" 877 | description = "A lil' TOML parser" 878 | optional = false 879 | python-versions = ">=3.7" 880 | files = [ 881 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 882 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 883 | ] 884 | 885 | [[package]] 886 | name = "tzdata" 887 | version = "2024.1" 888 | description = "Provider of IANA time zone data" 889 | optional = false 890 | python-versions = ">=2" 891 | files = [ 892 | {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, 893 | {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, 894 | ] 895 | 896 | [[package]] 897 | name = "urllib3" 898 | version = "2.2.2" 899 | description = "HTTP library with thread-safe connection pooling, file post, and more." 900 | optional = false 901 | python-versions = ">=3.8" 902 | files = [ 903 | {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, 904 | {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, 905 | ] 906 | 907 | [package.extras] 908 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 909 | h2 = ["h2 (>=4,<5)"] 910 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 911 | zstd = ["zstandard (>=0.18.0)"] 912 | 913 | [[package]] 914 | name = "zipp" 915 | version = "3.20.0" 916 | description = "Backport of pathlib-compatible object wrapper for zip files" 917 | optional = false 918 | python-versions = ">=3.8" 919 | files = [ 920 | {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, 921 | {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, 922 | ] 923 | 924 | [package.extras] 925 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 926 | test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] 927 | 928 | [metadata] 929 | lock-version = "2.0" 930 | python-versions = ">=3.8, <3.12" 931 | content-hash = "32296927e826f91f0f9a5f92d2c4905fe87f76b175724d72932fe0d07a92af47" 932 | --------------------------------------------------------------------------------