├── .codespell-ignore ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pylintrc ├── pyproject.toml ├── scripts ├── __init__.py ├── build.sh ├── build_and_publish.sh ├── clean.sh ├── common.sh ├── integration_test.py └── update_deps.sh ├── setup.cfg ├── tests ├── __init__.py ├── common.py ├── test_common.py └── test_init.py └── withings_api ├── __init__.py ├── common.py └── const.py /.codespell-ignore: -------------------------------------------------------------------------------- 1 | Withings -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: [3.7, 3.8] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Build 20 | run: | 21 | ./scripts/build.sh 22 | env: 23 | CI: 1 24 | - name: ReportGenerator 25 | uses: danielpalme/ReportGenerator-GitHub-Action@4.8.2 26 | if: ${{ matrix.python-version == '3.8' }} 27 | with: 28 | reports: './build/coverage.xml' 29 | targetdir: 'build' 30 | reporttypes: 'lcov' 31 | - name: Coveralls 32 | uses: coverallsapp/github-action@master 33 | if: ${{ matrix.python-version == '3.8' }} 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | path-to-lcov: ./build/lcov.info 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.8 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.8 16 | - name: Build and publish 17 | run: | 18 | ./scripts/build_and_publish.sh ${{ secrets.PYPI_PASSWORD }} 19 | env: 20 | CI: 1 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | **/__pycache__ 3 | *~ 4 | .tox 5 | .coverage 6 | *.egg-info 7 | .venv 8 | venv 9 | dist 10 | build 11 | .eggs 12 | .idea 13 | .credentials 14 | .mypy_cache 15 | .pytest_cache 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (C) 2012-2017 Maxime Bouroumeau-Fuseau 4 | Copyright (C) 2017-2019 ORCAS 5 | Copyright (C) 2019-2020 Robbie Van Gorkom 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python withings-api [![Build status](https://github.com/vangorra/python_withings_api/workflows/Build/badge.svg?branch=master)](https://github.com/vangorra/python_withings_api/actions?workflow=Build) [![Coverage Status](https://coveralls.io/repos/github/vangorra/python_withings_api/badge.svg?branch=master)](https://coveralls.io/github/vangorra/python_withings_api?branch=master) [![PyPI](https://img.shields.io/pypi/v/withings-api)](https://pypi.org/project/withings-api/) 2 | Python library for the Withings Health API 3 | 4 | 5 | Withings Health API 6 | 7 | 8 | Uses OAuth 2.0 to authenticate. You need to obtain a client id 9 | and consumer secret from Withings by creating an application 10 | here: 11 | 12 | ## Installation 13 | 14 | pip install withings-api 15 | 16 | ## Usage 17 | For a complete example, checkout the integration test in `scripts/integration_test.py`. It has a working example on how to use the API. 18 | ```python 19 | from withings_api import WithingsAuth, WithingsApi, AuthScope 20 | from withings_api.common import get_measure_value, MeasureType 21 | 22 | auth = WithingsAuth( 23 | client_id='your client id', 24 | consumer_secret='your consumer secret', 25 | callback_uri='your callback uri', 26 | mode='demo', # Used for testing. Remove this when getting real user data. 27 | scope=( 28 | AuthScope.USER_ACTIVITY, 29 | AuthScope.USER_METRICS, 30 | AuthScope.USER_INFO, 31 | AuthScope.USER_SLEEP_EVENTS, 32 | ) 33 | ) 34 | 35 | authorize_url = auth.get_authorize_url() 36 | # Have the user goto authorize_url and authorize the app. They will be redirected back to your redirect_uri. 37 | 38 | credentials = auth.get_credentials('code from the url args of redirect_uri') 39 | 40 | # Now you are ready to make calls for data. 41 | api = WithingsApi(credentials) 42 | 43 | meas_result = api.measure_get_meas() 44 | weight_or_none = get_measure_value(meas_result, with_measure_type=MeasureType.WEIGHT) 45 | ``` 46 | 47 | ## Building 48 | Building, testing and lintings of the project is all done with one script. You only need a few dependencies. 49 | 50 | Dependencies: 51 | - python3 in your path. 52 | - The python3 `venv` module. 53 | 54 | The build script will setup the venv, dependencies, test and lint and bundle the project. 55 | ```bash 56 | ./scripts/build.sh 57 | ``` 58 | 59 | ## Integration Testing 60 | There exists a simple integration test that runs against Withings' demo data. It's best to run this after you have 61 | successful builds. 62 | 63 | Note: after changing the source, you need to run build for the integration test to pickup the changes. 64 | 65 | ```bash 66 | ./scripts/build.sh 67 | source ./venv/bin/activate 68 | ./scripts/integration_test.py --client-id --consumer-secret --callback-uri 69 | ``` 70 | The integration test will cache the credentials in a `/.credentials` file between runs. If you get an error saying 71 | the access token expired, then remove that credentials file and try again. 72 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.3" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "arrow" 11 | version = "1.0.3" 12 | description = "Better dates & times for Python" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.6" 16 | 17 | [package.dependencies] 18 | python-dateutil = ">=2.7.0" 19 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 20 | 21 | [[package]] 22 | name = "astroid" 23 | version = "2.4.2" 24 | description = "An abstract syntax tree for Python with inference support." 25 | category = "dev" 26 | optional = false 27 | python-versions = ">=3.5" 28 | 29 | [package.dependencies] 30 | lazy-object-proxy = ">=1.4.0,<1.5.0" 31 | six = ">=1.12,<2.0" 32 | typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} 33 | wrapt = ">=1.11,<2.0" 34 | 35 | [[package]] 36 | name = "atomicwrites" 37 | version = "1.4.0" 38 | description = "Atomic file writes." 39 | category = "dev" 40 | optional = false 41 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 42 | 43 | [[package]] 44 | name = "attrs" 45 | version = "19.3.0" 46 | description = "Classes Without Boilerplate" 47 | category = "dev" 48 | optional = false 49 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 50 | 51 | [package.extras] 52 | azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] 53 | dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] 54 | docs = ["sphinx", "zope.interface"] 55 | tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 56 | 57 | [[package]] 58 | name = "bandit" 59 | version = "1.6.2" 60 | description = "Security oriented static analyser for python code." 61 | category = "dev" 62 | optional = false 63 | python-versions = "*" 64 | 65 | [package.dependencies] 66 | colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} 67 | GitPython = ">=1.0.1" 68 | PyYAML = ">=3.13" 69 | six = ">=1.10.0" 70 | stevedore = ">=1.20.0" 71 | 72 | [[package]] 73 | name = "black" 74 | version = "19.10b0" 75 | description = "The uncompromising code formatter." 76 | category = "dev" 77 | optional = false 78 | python-versions = ">=3.6" 79 | 80 | [package.dependencies] 81 | appdirs = "*" 82 | attrs = ">=18.1.0" 83 | click = ">=6.5" 84 | pathspec = ">=0.6,<1" 85 | regex = "*" 86 | toml = ">=0.9.4" 87 | typed-ast = ">=1.4.0" 88 | 89 | [package.extras] 90 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 91 | 92 | [[package]] 93 | name = "certifi" 94 | version = "2020.4.5.1" 95 | description = "Python package for providing Mozilla's CA Bundle." 96 | category = "main" 97 | optional = false 98 | python-versions = "*" 99 | 100 | [[package]] 101 | name = "charset-normalizer" 102 | version = "2.0.11" 103 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 104 | category = "main" 105 | optional = false 106 | python-versions = ">=3.5.0" 107 | 108 | [package.extras] 109 | unicode_backport = ["unicodedata2"] 110 | 111 | [[package]] 112 | name = "click" 113 | version = "7.1.2" 114 | description = "Composable command line interface toolkit" 115 | category = "dev" 116 | optional = false 117 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 118 | 119 | [[package]] 120 | name = "codespell" 121 | version = "1.16.0" 122 | description = "Codespell" 123 | category = "dev" 124 | optional = false 125 | python-versions = "*" 126 | 127 | [[package]] 128 | name = "colorama" 129 | version = "0.4.3" 130 | description = "Cross-platform colored terminal text." 131 | category = "dev" 132 | optional = false 133 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 134 | 135 | [[package]] 136 | name = "coverage" 137 | version = "5.0.4" 138 | description = "Code coverage measurement for Python" 139 | category = "dev" 140 | optional = false 141 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 142 | 143 | [package.extras] 144 | toml = ["toml"] 145 | 146 | [[package]] 147 | name = "dataclasses" 148 | version = "0.6" 149 | description = "A backport of the dataclasses module for Python 3.6" 150 | category = "main" 151 | optional = false 152 | python-versions = "*" 153 | 154 | [[package]] 155 | name = "entrypoints" 156 | version = "0.3" 157 | description = "Discover and load entry points from installed packages." 158 | category = "dev" 159 | optional = false 160 | python-versions = ">=2.7" 161 | 162 | [[package]] 163 | name = "flake8" 164 | version = "3.7.8" 165 | description = "the modular source code checker: pep8, pyflakes and co" 166 | category = "dev" 167 | optional = false 168 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 169 | 170 | [package.dependencies] 171 | entrypoints = ">=0.3.0,<0.4.0" 172 | mccabe = ">=0.6.0,<0.7.0" 173 | pycodestyle = ">=2.5.0,<2.6.0" 174 | pyflakes = ">=2.1.0,<2.2.0" 175 | 176 | [[package]] 177 | name = "gitdb" 178 | version = "4.0.4" 179 | description = "Git Object Database" 180 | category = "dev" 181 | optional = false 182 | python-versions = ">=3.4" 183 | 184 | [package.dependencies] 185 | smmap = ">=3.0.1,<4" 186 | 187 | [[package]] 188 | name = "gitpython" 189 | version = "3.1.1" 190 | description = "Python Git Library" 191 | category = "dev" 192 | optional = false 193 | python-versions = ">=3.4" 194 | 195 | [package.dependencies] 196 | gitdb = ">=4.0.1,<5" 197 | 198 | [[package]] 199 | name = "idna" 200 | version = "2.9" 201 | description = "Internationalized Domain Names in Applications (IDNA)" 202 | category = "main" 203 | optional = false 204 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 205 | 206 | [[package]] 207 | name = "importlib-metadata" 208 | version = "1.6.0" 209 | description = "Read metadata from Python packages" 210 | category = "dev" 211 | optional = false 212 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 213 | 214 | [package.dependencies] 215 | zipp = ">=0.5" 216 | 217 | [package.extras] 218 | docs = ["sphinx", "rst.linker"] 219 | testing = ["packaging", "importlib-resources"] 220 | 221 | [[package]] 222 | name = "iniconfig" 223 | version = "1.1.1" 224 | description = "iniconfig: brain-dead simple config-ini parsing" 225 | category = "dev" 226 | optional = false 227 | python-versions = "*" 228 | 229 | [[package]] 230 | name = "isort" 231 | version = "4.3.21" 232 | description = "A Python utility / library to sort Python imports." 233 | category = "dev" 234 | optional = false 235 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 236 | 237 | [package.extras] 238 | pipfile = ["pipreqs", "requirementslib"] 239 | pyproject = ["toml"] 240 | requirements = ["pipreqs", "pip-api"] 241 | xdg_home = ["appdirs (>=1.4.0)"] 242 | 243 | [[package]] 244 | name = "lazy-object-proxy" 245 | version = "1.4.3" 246 | description = "A fast and thorough lazy object proxy." 247 | category = "dev" 248 | optional = false 249 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 250 | 251 | [[package]] 252 | name = "mccabe" 253 | version = "0.6.1" 254 | description = "McCabe checker, plugin for flake8" 255 | category = "dev" 256 | optional = false 257 | python-versions = "*" 258 | 259 | [[package]] 260 | name = "mypy" 261 | version = "0.790" 262 | description = "Optional static typing for Python" 263 | category = "dev" 264 | optional = false 265 | python-versions = ">=3.5" 266 | 267 | [package.dependencies] 268 | mypy-extensions = ">=0.4.3,<0.5.0" 269 | typed-ast = ">=1.4.0,<1.5.0" 270 | typing-extensions = ">=3.7.4" 271 | 272 | [package.extras] 273 | dmypy = ["psutil (>=4.0)"] 274 | 275 | [[package]] 276 | name = "mypy-extensions" 277 | version = "0.4.3" 278 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 279 | category = "dev" 280 | optional = false 281 | python-versions = "*" 282 | 283 | [[package]] 284 | name = "oauthlib" 285 | version = "3.1.0" 286 | description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" 287 | category = "main" 288 | optional = false 289 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 290 | 291 | [package.extras] 292 | rsa = ["cryptography"] 293 | signals = ["blinker"] 294 | signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] 295 | 296 | [[package]] 297 | name = "packaging" 298 | version = "20.3" 299 | description = "Core utilities for Python packages" 300 | category = "dev" 301 | optional = false 302 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 303 | 304 | [package.dependencies] 305 | pyparsing = ">=2.0.2" 306 | six = "*" 307 | 308 | [[package]] 309 | name = "pathspec" 310 | version = "0.8.0" 311 | description = "Utility library for gitignore style pattern matching of file paths." 312 | category = "dev" 313 | optional = false 314 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 315 | 316 | [[package]] 317 | name = "pbr" 318 | version = "5.4.5" 319 | description = "Python Build Reasonableness" 320 | category = "dev" 321 | optional = false 322 | python-versions = "*" 323 | 324 | [[package]] 325 | name = "pluggy" 326 | version = "0.13.1" 327 | description = "plugin and hook calling mechanisms for python" 328 | category = "dev" 329 | optional = false 330 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 331 | 332 | [package.dependencies] 333 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 334 | 335 | [package.extras] 336 | dev = ["pre-commit", "tox"] 337 | 338 | [[package]] 339 | name = "py" 340 | version = "1.9.0" 341 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 342 | category = "dev" 343 | optional = false 344 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 345 | 346 | [[package]] 347 | name = "pycodestyle" 348 | version = "2.5.0" 349 | description = "Python style guide checker" 350 | category = "dev" 351 | optional = false 352 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 353 | 354 | [[package]] 355 | name = "pydantic" 356 | version = "1.7.4" 357 | description = "Data validation and settings management using python 3.6 type hinting" 358 | category = "main" 359 | optional = false 360 | python-versions = ">=3.6" 361 | 362 | [package.dependencies] 363 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 364 | 365 | [package.extras] 366 | dotenv = ["python-dotenv (>=0.10.4)"] 367 | email = ["email-validator (>=1.0.3)"] 368 | typing_extensions = ["typing-extensions (>=3.7.2)"] 369 | 370 | [[package]] 371 | name = "pyflakes" 372 | version = "2.1.1" 373 | description = "passive checker of Python programs" 374 | category = "dev" 375 | optional = false 376 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 377 | 378 | [[package]] 379 | name = "pylint" 380 | version = "2.6.0" 381 | description = "python code static checker" 382 | category = "dev" 383 | optional = false 384 | python-versions = ">=3.5.*" 385 | 386 | [package.dependencies] 387 | astroid = ">=2.4.0,<=2.5" 388 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 389 | isort = ">=4.2.5,<6" 390 | mccabe = ">=0.6,<0.7" 391 | toml = ">=0.7.1" 392 | 393 | [[package]] 394 | name = "pyparsing" 395 | version = "2.4.7" 396 | description = "Python parsing module" 397 | category = "dev" 398 | optional = false 399 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 400 | 401 | [[package]] 402 | name = "pytest" 403 | version = "6.1.2" 404 | description = "pytest: simple powerful testing with Python" 405 | category = "dev" 406 | optional = false 407 | python-versions = ">=3.5" 408 | 409 | [package.dependencies] 410 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 411 | attrs = ">=17.4.0" 412 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 413 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 414 | iniconfig = "*" 415 | packaging = "*" 416 | pluggy = ">=0.12,<1.0" 417 | py = ">=1.8.2" 418 | toml = "*" 419 | 420 | [package.extras] 421 | checkqa_mypy = ["mypy (==0.780)"] 422 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 423 | 424 | [[package]] 425 | name = "pytest-cov" 426 | version = "2.10.1" 427 | description = "Pytest plugin for measuring coverage." 428 | category = "dev" 429 | optional = false 430 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 431 | 432 | [package.dependencies] 433 | coverage = ">=4.4" 434 | pytest = ">=4.6" 435 | 436 | [package.extras] 437 | testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] 438 | 439 | [[package]] 440 | name = "python-dateutil" 441 | version = "2.8.1" 442 | description = "Extensions to the standard Python datetime module" 443 | category = "main" 444 | optional = false 445 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 446 | 447 | [package.dependencies] 448 | six = ">=1.5" 449 | 450 | [[package]] 451 | name = "pyyaml" 452 | version = "5.4" 453 | description = "YAML parser and emitter for Python" 454 | category = "dev" 455 | optional = false 456 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 457 | 458 | [[package]] 459 | name = "regex" 460 | version = "2020.4.4" 461 | description = "Alternative regular expression module, to replace re." 462 | category = "dev" 463 | optional = false 464 | python-versions = "*" 465 | 466 | [[package]] 467 | name = "requests" 468 | version = "2.27.1" 469 | description = "Python HTTP for Humans." 470 | category = "main" 471 | optional = false 472 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 473 | 474 | [package.dependencies] 475 | certifi = ">=2017.4.17" 476 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 477 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 478 | urllib3 = ">=1.21.1,<1.27" 479 | 480 | [package.extras] 481 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 482 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 483 | 484 | [[package]] 485 | name = "requests-oauth" 486 | version = "0.4.1" 487 | description = "Hook for adding Open Authentication support to Python-requests HTTP library." 488 | category = "main" 489 | optional = false 490 | python-versions = "*" 491 | 492 | [package.dependencies] 493 | requests = ">=0.12.1" 494 | 495 | [[package]] 496 | name = "requests-oauthlib" 497 | version = "1.3.0" 498 | description = "OAuthlib authentication support for Requests." 499 | category = "main" 500 | optional = false 501 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 502 | 503 | [package.dependencies] 504 | oauthlib = ">=3.0.0" 505 | requests = ">=2.0.0" 506 | 507 | [package.extras] 508 | rsa = ["oauthlib[signedtoken] (>=3.0.0)"] 509 | 510 | [[package]] 511 | name = "responses" 512 | version = "0.10.6" 513 | description = "A utility library for mocking out the `requests` Python library." 514 | category = "dev" 515 | optional = false 516 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 517 | 518 | [package.dependencies] 519 | requests = ">=2.0" 520 | six = "*" 521 | 522 | [package.extras] 523 | tests = ["pytest", "coverage (>=3.7.1,<5.0.0)", "pytest-cov", "pytest-localserver", "flake8"] 524 | 525 | [[package]] 526 | name = "six" 527 | version = "1.14.0" 528 | description = "Python 2 and 3 compatibility utilities" 529 | category = "main" 530 | optional = false 531 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 532 | 533 | [[package]] 534 | name = "smmap" 535 | version = "3.0.2" 536 | description = "A pure Python implementation of a sliding window memory map manager" 537 | category = "dev" 538 | optional = false 539 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 540 | 541 | [[package]] 542 | name = "stevedore" 543 | version = "1.32.0" 544 | description = "Manage dynamic plugins for Python applications" 545 | category = "dev" 546 | optional = false 547 | python-versions = "*" 548 | 549 | [package.dependencies] 550 | pbr = ">=2.0.0,<2.1.0 || >2.1.0" 551 | six = ">=1.10.0" 552 | 553 | [[package]] 554 | name = "toml" 555 | version = "0.10.0" 556 | description = "Python Library for Tom's Obvious, Minimal Language" 557 | category = "dev" 558 | optional = false 559 | python-versions = "*" 560 | 561 | [[package]] 562 | name = "typed-ast" 563 | version = "1.4.1" 564 | description = "a fork of Python 2 and 3 ast modules with type comment support" 565 | category = "dev" 566 | optional = false 567 | python-versions = "*" 568 | 569 | [[package]] 570 | name = "typing-extensions" 571 | version = "3.7.4.2" 572 | description = "Backported and Experimental Type Hints for Python 3.5+" 573 | category = "main" 574 | optional = false 575 | python-versions = "*" 576 | 577 | [[package]] 578 | name = "urllib3" 579 | version = "1.26.5" 580 | description = "HTTP library with thread-safe connection pooling, file post, and more." 581 | category = "main" 582 | optional = false 583 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 584 | 585 | [package.extras] 586 | brotli = ["brotlipy (>=0.6.0)"] 587 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 588 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 589 | 590 | [[package]] 591 | name = "wrapt" 592 | version = "1.11.2" 593 | description = "Module for decorators, wrappers and monkey patching." 594 | category = "dev" 595 | optional = false 596 | python-versions = "*" 597 | 598 | [[package]] 599 | name = "zipp" 600 | version = "3.1.0" 601 | description = "Backport of pathlib-compatible object wrapper for zip files" 602 | category = "dev" 603 | optional = false 604 | python-versions = ">=3.6" 605 | 606 | [package.extras] 607 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 608 | testing = ["jaraco.itertools", "func-timeout"] 609 | 610 | [metadata] 611 | lock-version = "1.1" 612 | python-versions = "^3.6 || ^3.7" 613 | content-hash = "218c50690bf011af2d51ec2310242f4e1f8bcfea78df73cb69efe0e8fa8e3c15" 614 | 615 | [metadata.files] 616 | appdirs = [ 617 | {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, 618 | {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, 619 | ] 620 | arrow = [ 621 | {file = "arrow-1.0.3-py3-none-any.whl", hash = "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543"}, 622 | {file = "arrow-1.0.3.tar.gz", hash = "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"}, 623 | ] 624 | astroid = [ 625 | {file = "astroid-2.4.2-py3-none-any.whl", hash = "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"}, 626 | {file = "astroid-2.4.2.tar.gz", hash = "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703"}, 627 | ] 628 | atomicwrites = [ 629 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 630 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 631 | ] 632 | attrs = [ 633 | {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, 634 | {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, 635 | ] 636 | bandit = [ 637 | {file = "bandit-1.6.2-py2.py3-none-any.whl", hash = "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952"}, 638 | {file = "bandit-1.6.2.tar.gz", hash = "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"}, 639 | ] 640 | black = [ 641 | {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, 642 | {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, 643 | ] 644 | certifi = [ 645 | {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, 646 | {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, 647 | ] 648 | charset-normalizer = [ 649 | {file = "charset-normalizer-2.0.11.tar.gz", hash = "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"}, 650 | {file = "charset_normalizer-2.0.11-py3-none-any.whl", hash = "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45"}, 651 | ] 652 | click = [ 653 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 654 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 655 | ] 656 | codespell = [ 657 | {file = "codespell-1.16.0-py3-none-any.whl", hash = "sha256:a81780122f955002d032a9a9c55e2305c6f252a530d8682ed5c3d0580f93d9b8"}, 658 | {file = "codespell-1.16.0.tar.gz", hash = "sha256:bf3b7c83327aefd26fe718527baa9bd61016e86db91a8123c0ef9c150fa02de9"}, 659 | ] 660 | colorama = [ 661 | {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, 662 | {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, 663 | ] 664 | coverage = [ 665 | {file = "coverage-5.0.4-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307"}, 666 | {file = "coverage-5.0.4-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8"}, 667 | {file = "coverage-5.0.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31"}, 668 | {file = "coverage-5.0.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441"}, 669 | {file = "coverage-5.0.4-cp27-cp27m-win32.whl", hash = "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac"}, 670 | {file = "coverage-5.0.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435"}, 671 | {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037"}, 672 | {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a"}, 673 | {file = "coverage-5.0.4-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5"}, 674 | {file = "coverage-5.0.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30"}, 675 | {file = "coverage-5.0.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7"}, 676 | {file = "coverage-5.0.4-cp35-cp35m-win32.whl", hash = "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de"}, 677 | {file = "coverage-5.0.4-cp35-cp35m-win_amd64.whl", hash = "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1"}, 678 | {file = "coverage-5.0.4-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1"}, 679 | {file = "coverage-5.0.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0"}, 680 | {file = "coverage-5.0.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd"}, 681 | {file = "coverage-5.0.4-cp36-cp36m-win32.whl", hash = "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0"}, 682 | {file = "coverage-5.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b"}, 683 | {file = "coverage-5.0.4-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78"}, 684 | {file = "coverage-5.0.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6"}, 685 | {file = "coverage-5.0.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014"}, 686 | {file = "coverage-5.0.4-cp37-cp37m-win32.whl", hash = "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732"}, 687 | {file = "coverage-5.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006"}, 688 | {file = "coverage-5.0.4-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2"}, 689 | {file = "coverage-5.0.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe"}, 690 | {file = "coverage-5.0.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9"}, 691 | {file = "coverage-5.0.4-cp38-cp38-win32.whl", hash = "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1"}, 692 | {file = "coverage-5.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0"}, 693 | {file = "coverage-5.0.4-cp39-cp39-win32.whl", hash = "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7"}, 694 | {file = "coverage-5.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892"}, 695 | {file = "coverage-5.0.4.tar.gz", hash = "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823"}, 696 | ] 697 | dataclasses = [ 698 | {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, 699 | {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, 700 | ] 701 | entrypoints = [ 702 | {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, 703 | {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, 704 | ] 705 | flake8 = [ 706 | {file = "flake8-3.7.8-py2.py3-none-any.whl", hash = "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"}, 707 | {file = "flake8-3.7.8.tar.gz", hash = "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548"}, 708 | ] 709 | gitdb = [ 710 | {file = "gitdb-4.0.4-py3-none-any.whl", hash = "sha256:ba1132c0912e8c917aa8aa990bee26315064c7b7f171ceaaac0afeb1dc656c6a"}, 711 | {file = "gitdb-4.0.4.tar.gz", hash = "sha256:6f0ecd46f99bb4874e5678d628c3a198e2b4ef38daea2756a2bfd8df7dd5c1a5"}, 712 | ] 713 | gitpython = [ 714 | {file = "GitPython-3.1.1-py3-none-any.whl", hash = "sha256:71b8dad7409efbdae4930f2b0b646aaeccce292484ffa0bc74f1195582578b3d"}, 715 | {file = "GitPython-3.1.1.tar.gz", hash = "sha256:6d4f10e2aaad1864bb0f17ec06a2c2831534140e5883c350d58b4e85189dab74"}, 716 | ] 717 | idna = [ 718 | {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, 719 | {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, 720 | ] 721 | importlib-metadata = [ 722 | {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, 723 | {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, 724 | ] 725 | iniconfig = [ 726 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 727 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 728 | ] 729 | isort = [ 730 | {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, 731 | {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, 732 | ] 733 | lazy-object-proxy = [ 734 | {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, 735 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, 736 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, 737 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, 738 | {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, 739 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, 740 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, 741 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, 742 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, 743 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, 744 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, 745 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, 746 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, 747 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, 748 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, 749 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, 750 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, 751 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, 752 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, 753 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, 754 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, 755 | ] 756 | mccabe = [ 757 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 758 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 759 | ] 760 | mypy = [ 761 | {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, 762 | {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, 763 | {file = "mypy-0.790-cp35-cp35m-win_amd64.whl", hash = "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de"}, 764 | {file = "mypy-0.790-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1"}, 765 | {file = "mypy-0.790-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc"}, 766 | {file = "mypy-0.790-cp36-cp36m-win_amd64.whl", hash = "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7"}, 767 | {file = "mypy-0.790-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"}, 768 | {file = "mypy-0.790-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178"}, 769 | {file = "mypy-0.790-cp37-cp37m-win_amd64.whl", hash = "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324"}, 770 | {file = "mypy-0.790-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01"}, 771 | {file = "mypy-0.790-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666"}, 772 | {file = "mypy-0.790-cp38-cp38-win_amd64.whl", hash = "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea"}, 773 | {file = "mypy-0.790-py3-none-any.whl", hash = "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122"}, 774 | {file = "mypy-0.790.tar.gz", hash = "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975"}, 775 | ] 776 | mypy-extensions = [ 777 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 778 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 779 | ] 780 | oauthlib = [ 781 | {file = "oauthlib-3.1.0-py2.py3-none-any.whl", hash = "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"}, 782 | {file = "oauthlib-3.1.0.tar.gz", hash = "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889"}, 783 | ] 784 | packaging = [ 785 | {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, 786 | {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, 787 | ] 788 | pathspec = [ 789 | {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, 790 | {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, 791 | ] 792 | pbr = [ 793 | {file = "pbr-5.4.5-py2.py3-none-any.whl", hash = "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8"}, 794 | {file = "pbr-5.4.5.tar.gz", hash = "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c"}, 795 | ] 796 | pluggy = [ 797 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 798 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 799 | ] 800 | py = [ 801 | {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, 802 | {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, 803 | ] 804 | pycodestyle = [ 805 | {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, 806 | {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, 807 | ] 808 | pydantic = [ 809 | {file = "pydantic-1.7.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3c60039e84552442defbcb5d56711ef0e057028ca7bfc559374917408a88d84e"}, 810 | {file = "pydantic-1.7.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6e7e314acb170e143c6f3912f93f2ec80a96aa2009ee681356b7ce20d57e5c62"}, 811 | {file = "pydantic-1.7.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:8ef77cd17b73b5ba46788d040c0e820e49a2d80cfcd66fda3ba8be31094fd146"}, 812 | {file = "pydantic-1.7.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:115d8aa6f257a1d469c66b6bfc7aaf04cd87c25095f24542065c68ebcb42fe63"}, 813 | {file = "pydantic-1.7.4-cp36-cp36m-win_amd64.whl", hash = "sha256:66757d4e1eab69a3cfd3114480cc1d72b6dd847c4d30e676ae838c6740fdd146"}, 814 | {file = "pydantic-1.7.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4c92863263e4bd89e4f9cf1ab70d918170c51bd96305fe7b00853d80660acb26"}, 815 | {file = "pydantic-1.7.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3b8154babf30a5e0fa3aa91f188356763749d9b30f7f211fafb247d4256d7877"}, 816 | {file = "pydantic-1.7.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:80cc46378505f7ff202879dcffe4bfbf776c15675028f6e08d1d10bdfbb168ac"}, 817 | {file = "pydantic-1.7.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:dda60d7878a5af2d8560c55c7c47a8908344aa78d32ec1c02d742ede09c534df"}, 818 | {file = "pydantic-1.7.4-cp37-cp37m-win_amd64.whl", hash = "sha256:4c1979d5cc3e14b35f0825caddea5a243dd6085e2a7539c006bc46997ef7a61a"}, 819 | {file = "pydantic-1.7.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8857576600c32aa488f18d30833aa833b54a48e3bab3adb6de97e463af71f8f8"}, 820 | {file = "pydantic-1.7.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1f86d4da363badb39426a0ff494bf1d8510cd2f7274f460eee37bdbf2fd495ec"}, 821 | {file = "pydantic-1.7.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:3ea1256a9e782149381e8200119f3e2edea7cd6b123f1c79ab4bbefe4d9ba2c9"}, 822 | {file = "pydantic-1.7.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e28455b42a0465a7bf2cde5eab530389226ce7dc779de28d17b8377245982b1e"}, 823 | {file = "pydantic-1.7.4-cp38-cp38-win_amd64.whl", hash = "sha256:47c5b1d44934375a3311891cabd450c150a31cf5c22e84aa172967bf186718be"}, 824 | {file = "pydantic-1.7.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:00250e5123dd0b123ff72be0e1b69140e0b0b9e404d15be3846b77c6f1b1e387"}, 825 | {file = "pydantic-1.7.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d24aa3f7f791a023888976b600f2f389d3713e4f23b7a4c88217d3fce61cdffc"}, 826 | {file = "pydantic-1.7.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2c44a9afd4c4c850885436a4209376857989aaf0853c7b118bb2e628d4b78c4e"}, 827 | {file = "pydantic-1.7.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:e87edd753da0ca1d44e308a1b1034859ffeab1f4a4492276bff9e1c3230db4fe"}, 828 | {file = "pydantic-1.7.4-cp39-cp39-win_amd64.whl", hash = "sha256:a3026ee105b5360855e500b4abf1a1d0b034d88e75a2d0d66a4c35e60858e15b"}, 829 | {file = "pydantic-1.7.4-py3-none-any.whl", hash = "sha256:a82385c6d5a77e3387e94612e3e34b77e13c39ff1295c26e3ba664e7b98073e2"}, 830 | {file = "pydantic-1.7.4.tar.gz", hash = "sha256:0a1abcbd525fbb52da58c813d54c2ec706c31a91afdb75411a73dd1dec036595"}, 831 | ] 832 | pyflakes = [ 833 | {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, 834 | {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, 835 | ] 836 | pylint = [ 837 | {file = "pylint-2.6.0-py3-none-any.whl", hash = "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f"}, 838 | {file = "pylint-2.6.0.tar.gz", hash = "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210"}, 839 | ] 840 | pyparsing = [ 841 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 842 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 843 | ] 844 | pytest = [ 845 | {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, 846 | {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, 847 | ] 848 | pytest-cov = [ 849 | {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, 850 | {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, 851 | ] 852 | python-dateutil = [ 853 | {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, 854 | {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, 855 | ] 856 | pyyaml = [ 857 | {file = "PyYAML-5.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:f7a21e3d99aa3095ef0553e7ceba36fb693998fbb1226f1392ce33681047465f"}, 858 | {file = "PyYAML-5.4-cp27-cp27m-win32.whl", hash = "sha256:52bf0930903818e600ae6c2901f748bc4869c0c406056f679ab9614e5d21a166"}, 859 | {file = "PyYAML-5.4-cp27-cp27m-win_amd64.whl", hash = "sha256:a36a48a51e5471513a5aea920cdad84cbd56d70a5057cca3499a637496ea379c"}, 860 | {file = "PyYAML-5.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5e7ac4e0e79a53451dc2814f6876c2fa6f71452de1498bbe29c0b54b69a986f4"}, 861 | {file = "PyYAML-5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc552b6434b90d9dbed6a4f13339625dc466fd82597119897e9489c953acbc22"}, 862 | {file = "PyYAML-5.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0dc9f2eb2e3c97640928dec63fd8dc1dd91e6b6ed236bd5ac00332b99b5c2ff9"}, 863 | {file = "PyYAML-5.4-cp36-cp36m-win32.whl", hash = "sha256:5a3f345acff76cad4aa9cb171ee76c590f37394186325d53d1aa25318b0d4a09"}, 864 | {file = "PyYAML-5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:f3790156c606299ff499ec44db422f66f05a7363b39eb9d5b064f17bd7d7c47b"}, 865 | {file = "PyYAML-5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:124fd7c7bc1e95b1eafc60825f2daf67c73ce7b33f1194731240d24b0d1bf628"}, 866 | {file = "PyYAML-5.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8b818b6c5a920cbe4203b5a6b14256f0e5244338244560da89b7b0f1313ea4b6"}, 867 | {file = "PyYAML-5.4-cp37-cp37m-win32.whl", hash = "sha256:737bd70e454a284d456aa1fa71a0b429dd527bcbf52c5c33f7c8eee81ac16b89"}, 868 | {file = "PyYAML-5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:7242790ab6c20316b8e7bb545be48d7ed36e26bbe279fd56f2c4a12510e60b4b"}, 869 | {file = "PyYAML-5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cc547d3ead3754712223abb7b403f0a184e4c3eae18c9bb7fd15adef1597cc4b"}, 870 | {file = "PyYAML-5.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8635d53223b1f561b081ff4adecb828fd484b8efffe542edcfdff471997f7c39"}, 871 | {file = "PyYAML-5.4-cp38-cp38-win32.whl", hash = "sha256:26fcb33776857f4072601502d93e1a619f166c9c00befb52826e7b774efaa9db"}, 872 | {file = "PyYAML-5.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2243dd033fd02c01212ad5c601dafb44fbb293065f430b0d3dbf03f3254d615"}, 873 | {file = "PyYAML-5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:31ba07c54ef4a897758563e3a0fcc60077698df10180abe4b8165d9895c00ebf"}, 874 | {file = "PyYAML-5.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:02c78d77281d8f8d07a255e57abdbf43b02257f59f50cc6b636937d68efa5dd0"}, 875 | {file = "PyYAML-5.4-cp39-cp39-win32.whl", hash = "sha256:fdc6b2cb4b19e431994f25a9160695cc59a4e861710cc6fc97161c5e845fc579"}, 876 | {file = "PyYAML-5.4-cp39-cp39-win_amd64.whl", hash = "sha256:8bf38641b4713d77da19e91f8b5296b832e4db87338d6aeffe422d42f1ca896d"}, 877 | {file = "PyYAML-5.4.tar.gz", hash = "sha256:3c49e39ac034fd64fd576d63bb4db53cda89b362768a67f07749d55f128ac18a"}, 878 | ] 879 | regex = [ 880 | {file = "regex-2020.4.4-cp27-cp27m-win32.whl", hash = "sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f"}, 881 | {file = "regex-2020.4.4-cp27-cp27m-win_amd64.whl", hash = "sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1"}, 882 | {file = "regex-2020.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b"}, 883 | {file = "regex-2020.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db"}, 884 | {file = "regex-2020.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156"}, 885 | {file = "regex-2020.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3"}, 886 | {file = "regex-2020.4.4-cp36-cp36m-win32.whl", hash = "sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8"}, 887 | {file = "regex-2020.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a"}, 888 | {file = "regex-2020.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468"}, 889 | {file = "regex-2020.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6"}, 890 | {file = "regex-2020.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd"}, 891 | {file = "regex-2020.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948"}, 892 | {file = "regex-2020.4.4-cp37-cp37m-win32.whl", hash = "sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e"}, 893 | {file = "regex-2020.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a"}, 894 | {file = "regex-2020.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e"}, 895 | {file = "regex-2020.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683"}, 896 | {file = "regex-2020.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b"}, 897 | {file = "regex-2020.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"}, 898 | {file = "regex-2020.4.4-cp38-cp38-win32.whl", hash = "sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3"}, 899 | {file = "regex-2020.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3"}, 900 | {file = "regex-2020.4.4.tar.gz", hash = "sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142"}, 901 | ] 902 | requests = [ 903 | {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, 904 | {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, 905 | ] 906 | requests-oauth = [ 907 | {file = "requests-oauth-0.4.1.tar.gz", hash = "sha256:9c1b0738967ef1c0f6f0eb1ff09a7000499cd07a609ea8f1770b515b953af692"}, 908 | ] 909 | requests-oauthlib = [ 910 | {file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"}, 911 | {file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"}, 912 | {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"}, 913 | ] 914 | responses = [ 915 | {file = "responses-0.10.6-py2.py3-none-any.whl", hash = "sha256:97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"}, 916 | {file = "responses-0.10.6.tar.gz", hash = "sha256:502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790"}, 917 | ] 918 | six = [ 919 | {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, 920 | {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, 921 | ] 922 | smmap = [ 923 | {file = "smmap-3.0.2-py2.py3-none-any.whl", hash = "sha256:52ea78b3e708d2c2b0cfe93b6fc3fbeec53db913345c26be6ed84c11ed8bebc1"}, 924 | {file = "smmap-3.0.2.tar.gz", hash = "sha256:b46d3fc69ba5f367df96d91f8271e8ad667a198d5a28e215a6c3d9acd133a911"}, 925 | ] 926 | stevedore = [ 927 | {file = "stevedore-1.32.0-py2.py3-none-any.whl", hash = "sha256:a4e7dc759fb0f2e3e2f7d8ffe2358c19d45b9b8297f393ef1256858d82f69c9b"}, 928 | {file = "stevedore-1.32.0.tar.gz", hash = "sha256:18afaf1d623af5950cc0f7e75e70f917784c73b652a34a12d90b309451b5500b"}, 929 | ] 930 | toml = [ 931 | {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, 932 | {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, 933 | {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, 934 | ] 935 | typed-ast = [ 936 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 937 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 938 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 939 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 940 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 941 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 942 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 943 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, 944 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 945 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 946 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 947 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 948 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 949 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, 950 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 951 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 952 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 953 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 954 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 955 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, 956 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 957 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 958 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 959 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, 960 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, 961 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, 962 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, 963 | {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, 964 | {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, 965 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 966 | ] 967 | typing-extensions = [ 968 | {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, 969 | {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, 970 | {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, 971 | ] 972 | urllib3 = [ 973 | {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, 974 | {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, 975 | ] 976 | wrapt = [ 977 | {file = "wrapt-1.11.2.tar.gz", hash = "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"}, 978 | ] 979 | zipp = [ 980 | {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, 981 | {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, 982 | ] 983 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | extension-pkg-whitelist=pydantic 3 | jobs=4 4 | 5 | [MESSAGES CONTROL] 6 | # Reasons disabled: 7 | # format - handled by black 8 | # too-many-* - are not enforced for the sake of readability 9 | # too-few-* - same as too-many-* 10 | disable= 11 | format, 12 | too-many-arguments, 13 | too-few-public-methods 14 | 15 | [REPORTS] 16 | reports=no 17 | 18 | [TYPECHECK] 19 | # For attrs 20 | ignored-classes=responses 21 | 22 | [FORMAT] 23 | expected-line-ending-format=LF 24 | 25 | [EXCEPTIONS] 26 | overgeneral-exceptions=Exception 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "withings_api" 3 | version = "2.4.0" 4 | description = "Library for the Withings API" 5 | 6 | license = "MIT" 7 | 8 | authors = [ 9 | "Robbie Van Gorkom " 10 | ] 11 | 12 | readme = "README.md" 13 | 14 | repository = "https://github.com/vangorra/python_withings_api" 15 | homepage = "https://github.com/vangorra/python_withings_api" 16 | 17 | keywords = ["withings", "api"] 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | 23 | [tool.poetry.dependencies] 24 | python = "^3.6 || ^3.7" 25 | arrow = ">=1.0.3" 26 | requests-oauth = ">=0.4.1" 27 | requests-oauthlib = ">=1.2" 28 | typing-extensions = ">=3.7.4.2" 29 | pydantic = "^1.7.2" 30 | 31 | [tool.poetry.dev-dependencies] 32 | bandit = "==1.6.2" 33 | black = "==19.10b0" 34 | codespell = "==1.16.0" 35 | coverage = "==5.0.4" 36 | flake8 = "==3.7.8" 37 | isort = "==4.3.21" 38 | mypy = "==0.790" 39 | pylint = "==2.6.0" 40 | pytest = "==6.1.2" 41 | pytest-cov = "==2.10.1" 42 | responses = "==0.10.6" 43 | toml = "==0.10.0" # Needed by isort and others. 44 | wheel = "==0.33.6" # Needed for successful compile of other modules. 45 | 46 | 47 | [tool.black] 48 | target-version = ["py36", "py37", "py38"] 49 | exclude = ''' 50 | ( 51 | /( 52 | \.eggs # exclude a few common directories in the 53 | | \.git # root of the project 54 | | \.hg 55 | | \.mypy_cache 56 | | \.tox 57 | | \.venv 58 | | venv 59 | | build 60 | | _build 61 | | buck-out 62 | | build 63 | | dist 64 | )/ 65 | | foo.py # also separately exclude a file named foo.py in 66 | # the root of the project 67 | ) 68 | ''' 69 | 70 | [tool.isort] 71 | # https://github.com/timothycrosley/isort 72 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 73 | # splits long import on multiple lines indented by 4 spaces 74 | multi_line_output = 3 75 | include_trailing_comma = true 76 | force_grid_wrap = 0 77 | use_parentheses = true 78 | line_length = 88 79 | indent = " " 80 | # by default isort don't check module indexes 81 | not_skip = "__init__.py" 82 | # will group `import x` and `from x import` of the same module. 83 | force_sort_within_sections = true 84 | sections = "FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 85 | default_section = "THIRDPARTY" 86 | known_first_party = "homeassistant,tests" 87 | forced_separate = "tests" 88 | combine_as_imports = true 89 | 90 | 91 | [tool.coverage.run] 92 | branch = true 93 | 94 | [tool.coverage.report] 95 | fail_under = 99.0 96 | 97 | [tool.pytest.ini_options] 98 | testpaths = "tests" 99 | addopts = "--capture no --cov ./withings_api --cov-report html:build/coverage_report --cov-report term --cov-report xml:build/coverage.xml" 100 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """Scripts package.""" 2 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | cd "$SELF_DIR/.." 6 | 7 | source "$SELF_DIR/common.sh" 8 | 9 | assertPython 10 | 11 | 12 | echo 13 | echo "===Setting up venv===" 14 | enterVenv 15 | 16 | 17 | echo 18 | echo "===Installing poetry===" 19 | pip install poetry 20 | 21 | 22 | echo 23 | echo "===Installing dependencies===" 24 | poetry install 25 | 26 | 27 | echo 28 | echo "===Sorting imports===" 29 | ISORT_ARGS="--apply" 30 | if [[ "${CI:-}" = "1" ]]; then 31 | ISORT_ARGS="--check-only" 32 | fi 33 | 34 | isort $ISORT_ARGS 35 | 36 | 37 | echo 38 | echo "===Formatting code===" 39 | BLACK_ARGS="" 40 | if [[ "${CI:-}" = "1" ]]; then 41 | BLACK_ARGS="--check" 42 | fi 43 | 44 | black $BLACK_ARGS . 45 | 46 | 47 | echo 48 | echo "===Lint with bandit===" 49 | bandit \ 50 | --recursive \ 51 | --exclude '**/__pycache__/*' \ 52 | ./withings_api 53 | 54 | 55 | echo 56 | echo "===Lint with codespell===" 57 | codespell \ 58 | --check-filenames \ 59 | --ignore-words ./.codespell-ignore \ 60 | --skip "*/__pycache__/*" \ 61 | ./withings_api ./test ./scripts 62 | 63 | 64 | echo 65 | echo "===Lint with flake8===" 66 | flake8 67 | 68 | 69 | echo 70 | echo "===Lint with mypy===" 71 | mypy . 72 | 73 | 74 | echo 75 | echo "===Lint with pylint===" 76 | pylint $LINT_PATHS 77 | 78 | 79 | echo 80 | echo "===Test with pytest===" 81 | pytest 82 | 83 | 84 | echo 85 | echo "===Building package===" 86 | poetry build 87 | 88 | 89 | echo 90 | echo "Build complete" 91 | -------------------------------------------------------------------------------- /scripts/build_and_publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | PUBLISH_PASSWORD="$1" 6 | 7 | source "$SELF_DIR/common.sh" 8 | 9 | assertPython 10 | 11 | "$SELF_DIR/build.sh" 12 | 13 | echo 14 | echo "===Setting up venv===" 15 | enterVenv 16 | 17 | 18 | echo 19 | echo "===Publishing package===" 20 | poetry publish --username __token__ --password "$PUBLISH_PASSWORD" 21 | -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | cd "$SELF_DIR/.." 6 | 7 | if [[ `env | grep VIRTUAL_ENV` ]]; then 8 | echo "Error: deactivate your venv first." 9 | exit 1 10 | fi 11 | 12 | find . -regex '^.*\(__pycache__\|\.py[co]\)$' -delete 13 | rm .coverage .eggs .tox build dist withings*.egg-info .venv venv -rf 14 | 15 | echo "Clean complete." 16 | -------------------------------------------------------------------------------- /scripts/common.sh: -------------------------------------------------------------------------------- 1 | VENV_DIR=".venv" 2 | PYTHON_BIN="python3" 3 | LINT_PATHS="./withings_api ./tests/ ./scripts/" 4 | 5 | function assertPython() { 6 | if ! [[ $(which "$PYTHON_BIN") ]]; then 7 | echo "Error: '$PYTHON_BIN' is not in your path." 8 | exit 1 9 | fi 10 | } 11 | 12 | function enterVenv() { 13 | # Not sure why I couldn't use "if ! [[ `"$PYTHON_BIN" -c 'import venv'` ]]" below. It just never worked when venv was 14 | # present. 15 | VENV_NOT_INSTALLED=$("$PYTHON_BIN" -c 'import venv' 2>&1 | grep -ic ' No module named' || true) 16 | if [[ "$VENV_NOT_INSTALLED" -gt "0" ]]; then 17 | echo "Error: The $PYTHON_BIN 'venv' module is not installed." 18 | exit 1 19 | fi 20 | 21 | if ! [[ -e "$VENV_DIR" ]]; then 22 | echo "Creating venv." 23 | "$PYTHON_BIN" -m venv "$VENV_DIR" 24 | else 25 | echo Using existing venv. 26 | fi 27 | 28 | if ! [[ $(env | grep VIRTUAL_ENV) ]]; then 29 | echo "Entering venv." 30 | set +uf 31 | source "$VENV_DIR/bin/activate" 32 | set -uf 33 | else 34 | echo Already in venv. 35 | fi 36 | 37 | } -------------------------------------------------------------------------------- /scripts/integration_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Integration test.""" 3 | import argparse 4 | import os 5 | from os import path 6 | import pickle 7 | from typing import cast 8 | from urllib import parse 9 | 10 | import arrow 11 | from oauthlib.oauth2.rfc6749.errors import MissingTokenError 12 | from typing_extensions import Final 13 | from withings_api import AuthScope, WithingsApi, WithingsAuth 14 | from withings_api.common import CredentialsType, GetSleepField, GetSleepSummaryField 15 | 16 | CREDENTIALS_FILE: Final = path.abspath( 17 | path.join(path.dirname(path.abspath(__file__)), "../.credentials") 18 | ) 19 | 20 | 21 | def save_credentials(credentials: CredentialsType) -> None: 22 | """Save credentials to a file.""" 23 | print("Saving credentials in:", CREDENTIALS_FILE) 24 | with open(CREDENTIALS_FILE, "wb") as file_handle: 25 | pickle.dump(credentials, file_handle) 26 | 27 | 28 | def load_credentials() -> CredentialsType: 29 | """Load credentials from a file.""" 30 | print("Using credentials saved in:", CREDENTIALS_FILE) 31 | with open(CREDENTIALS_FILE, "rb") as file_handle: 32 | return cast(CredentialsType, pickle.load(file_handle)) 33 | 34 | 35 | def main() -> None: 36 | """Run main function.""" 37 | parser: Final = argparse.ArgumentParser(description="Process some integers.") 38 | parser.add_argument( 39 | "--client-id", 40 | dest="client_id", 41 | help="Client id provided by withings.", 42 | required=True, 43 | ) 44 | parser.add_argument( 45 | "--consumer-secret", 46 | dest="consumer_secret", 47 | help="Consumer secret provided by withings.", 48 | required=True, 49 | ) 50 | parser.add_argument( 51 | "--callback-uri", 52 | dest="callback_uri", 53 | help="Callback URI configured for withings application.", 54 | required=True, 55 | ) 56 | parser.add_argument( 57 | "--live-data", 58 | dest="live_data", 59 | action="store_true", 60 | help="Should we run against live data? (Removal of .credentials file is required before running)", 61 | ) 62 | 63 | args: Final = parser.parse_args() 64 | 65 | if path.isfile(CREDENTIALS_FILE): 66 | print("Attempting to load credentials from:", CREDENTIALS_FILE) 67 | api = WithingsApi(load_credentials(), refresh_cb=save_credentials) 68 | try: 69 | api.user_get_device() 70 | except MissingTokenError: 71 | os.remove(CREDENTIALS_FILE) 72 | print("Credentials in file are expired. Re-starting auth procedure...") 73 | 74 | if not path.isfile(CREDENTIALS_FILE): 75 | print("Attempting to get credentials...") 76 | auth: Final = WithingsAuth( 77 | client_id=args.client_id, 78 | consumer_secret=args.consumer_secret, 79 | callback_uri=args.callback_uri, 80 | mode=None if args.live_data else "demo", 81 | scope=( 82 | AuthScope.USER_ACTIVITY, 83 | AuthScope.USER_METRICS, 84 | AuthScope.USER_INFO, 85 | AuthScope.USER_SLEEP_EVENTS, 86 | ), 87 | ) 88 | 89 | authorize_url: Final = auth.get_authorize_url() 90 | print("Goto this URL in your browser and authorize:", authorize_url) 91 | print( 92 | "Once you are redirected, copy and paste the whole url" 93 | "(including code) here." 94 | ) 95 | redirected_uri: Final = input("Provide the entire redirect uri: ") 96 | redirected_uri_params: Final = dict( 97 | parse.parse_qsl(parse.urlsplit(redirected_uri).query) 98 | ) 99 | auth_code: Final = redirected_uri_params["code"] 100 | 101 | print("Getting credentials with auth code", auth_code) 102 | save_credentials(auth.get_credentials(auth_code)) 103 | 104 | api = WithingsApi(load_credentials(), refresh_cb=save_credentials) 105 | 106 | # This only tests the refresh token. Token refresh is handled automatically by the api so you should not 107 | # need to use this method so long as your code regularly (every 3 hours or so) requests data from withings. 108 | orig_access_token = api.get_credentials().access_token 109 | print("Refreshing token...") 110 | api.refresh_token() 111 | assert orig_access_token != api.get_credentials().access_token 112 | 113 | print("Getting devices...") 114 | assert api.user_get_device() is not None 115 | 116 | print("Getting measures...") 117 | assert ( 118 | api.measure_get_meas( 119 | startdate=arrow.utcnow().shift(days=-21), enddate=arrow.utcnow() 120 | ) 121 | is not None 122 | ) 123 | 124 | print("Getting activity...") 125 | assert ( 126 | api.measure_get_activity( 127 | startdateymd=arrow.utcnow().shift(days=-21), enddateymd=arrow.utcnow() 128 | ) 129 | is not None 130 | ) 131 | 132 | print("Getting sleep...") 133 | assert ( 134 | api.sleep_get( 135 | data_fields=GetSleepField, 136 | startdate=arrow.utcnow().shift(days=-2), 137 | enddate=arrow.utcnow(), 138 | ) 139 | is not None 140 | ) 141 | 142 | print("Getting sleep summary...") 143 | assert ( 144 | api.sleep_get_summary( 145 | data_fields=GetSleepSummaryField, 146 | startdateymd=arrow.utcnow().shift(days=-2), 147 | enddateymd=arrow.utcnow(), 148 | ) 149 | is not None 150 | ) 151 | 152 | print("Getting subscriptions...") 153 | assert api.notify_list() is not None 154 | 155 | print("Successfully finished.") 156 | 157 | 158 | if __name__ == "__main__": 159 | main() 160 | -------------------------------------------------------------------------------- /scripts/update_deps.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | cd "$SELF_DIR/.." 6 | 7 | source "$SELF_DIR/common.sh" 8 | 9 | assertPython 10 | 11 | 12 | echo 13 | echo "===Setting up venv===" 14 | enterVenv 15 | 16 | 17 | echo 18 | echo "===Installing poetry===" 19 | pip install poetry 20 | 21 | 22 | echo 23 | echo "===Installing dependencies===" 24 | poetry install 25 | 26 | 27 | echo 28 | echo "===Updating poetry lock file===" 29 | poetry update --lock 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = venv,.venv,.git,.tox,.eggs,docs,venv,bin,lib,deps,build 3 | # To work with Black 4 | max-line-length = 88 5 | # E501: line too long 6 | # W503: Line break occurred before a binary operator 7 | # E203: Whitespace before ':' 8 | # D202 No blank lines allowed after function docstring 9 | # W504 line break after binary operator 10 | ignore = 11 | E501, 12 | W503, 13 | E203, 14 | D202, 15 | W504 16 | 17 | [mypy] 18 | plugins = pydantic.mypy 19 | 20 | ignore_missing_imports = True 21 | follow_imports = normal 22 | follow_imports_for_stubs = True 23 | 24 | disallow_subclassing_any = True 25 | 26 | disallow_untyped_calls = True 27 | disallow_untyped_defs = True 28 | disallow_incomplete_defs = True 29 | check_untyped_defs = True 30 | 31 | no_implicit_optional = True 32 | 33 | warn_unused_ignores = True 34 | warn_return_any = True 35 | warn_unreachable = True 36 | 37 | implicit_reexport = True 38 | strict_equality = True 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package.""" 2 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Common test code.""" 2 | from datetime import tzinfo 3 | from typing import cast 4 | 5 | from dateutil import tz 6 | from typing_extensions import Final 7 | 8 | TIMEZONE_STR0: Final = "Europe/London" 9 | TIMEZONE_STR1: Final = "America/Los_Angeles" 10 | TIMEZONE0: Final = cast(tzinfo, tz.gettz(TIMEZONE_STR0)) 11 | TIMEZONE1: Final = cast(tzinfo, tz.gettz(TIMEZONE_STR1)) 12 | -------------------------------------------------------------------------------- /tests/test_common.py: -------------------------------------------------------------------------------- 1 | """Tests for common code.""" 2 | from typing import Any, Dict 3 | 4 | import arrow 5 | import pytest 6 | from typing_extensions import Final 7 | from withings_api.common import ( 8 | ArrowType, 9 | AuthFailedException, 10 | BadStateException, 11 | Credentials, 12 | Credentials2, 13 | ErrorOccurredException, 14 | InvalidParamsException, 15 | MeasureGetMeasGroup, 16 | MeasureGetMeasGroupAttrib, 17 | MeasureGetMeasGroupCategory, 18 | MeasureGetMeasMeasure, 19 | MeasureGetMeasResponse, 20 | MeasureGroupAttribs, 21 | MeasureType, 22 | MeasureTypes, 23 | TimeoutException, 24 | TimeZone, 25 | TooManyRequestsException, 26 | UnauthorizedException, 27 | UnexpectedTypeException, 28 | UnknownStatusException, 29 | get_measure_value, 30 | maybe_upgrade_credentials, 31 | query_measure_groups, 32 | response_body_or_raise, 33 | ) 34 | from withings_api.const import ( 35 | STATUS_AUTH_FAILED, 36 | STATUS_BAD_STATE, 37 | STATUS_ERROR_OCCURRED, 38 | STATUS_INVALID_PARAMS, 39 | STATUS_SUCCESS, 40 | STATUS_TIMEOUT, 41 | STATUS_TOO_MANY_REQUESTS, 42 | STATUS_UNAUTHORIZED, 43 | ) 44 | 45 | from .common import TIMEZONE0, TIMEZONE_STR0 46 | 47 | 48 | def test_time_zone_validate() -> None: 49 | """Test TimeZone conversation.""" 50 | with pytest.raises(TypeError): 51 | assert TimeZone.validate(123) 52 | with pytest.raises(ValueError): 53 | assert TimeZone.validate("NOT_A_TIMEZONE") 54 | assert TimeZone.validate(TIMEZONE_STR0) == TIMEZONE0 55 | 56 | 57 | def test_arrow_type_validate() -> None: 58 | """Test ArrowType conversation.""" 59 | with pytest.raises(TypeError): 60 | assert ArrowType.validate(1.23) 61 | 62 | arrow_obj: Final = arrow.get(1234567) 63 | assert ArrowType.validate("1234567") == arrow_obj 64 | assert ArrowType.validate(str(arrow_obj)) == arrow_obj 65 | assert ArrowType.validate(1234567) == arrow_obj 66 | assert ArrowType.validate(arrow_obj) == arrow_obj 67 | 68 | 69 | def test_maybe_update_credentials() -> None: 70 | """Test upgrade credentials objects.""" 71 | 72 | creds1: Final = Credentials( 73 | access_token="my_access_token", 74 | token_expiry=arrow.get("2020-01-01T00:00:00+07:00").int_timestamp, 75 | token_type="Bearer", 76 | refresh_token="my_refresh_token", 77 | userid=1, 78 | client_id="CLIENT_ID", 79 | consumer_secret="CONSUMER_SECRET", 80 | ) 81 | 82 | expires_in = creds1.token_expiry - arrow.utcnow().int_timestamp 83 | creds2: Final = Credentials2( 84 | access_token="my_access_token", 85 | expires_in=expires_in, 86 | token_type="Bearer", 87 | refresh_token="my_refresh_token", 88 | userid=1, 89 | client_id="CLIENT_ID", 90 | consumer_secret="CONSUMER_SECRET", 91 | ) 92 | 93 | assert maybe_upgrade_credentials(creds2) == creds2 94 | 95 | upgraded_creds: Final = maybe_upgrade_credentials(creds1) 96 | assert upgraded_creds.access_token == creds1.access_token 97 | assert upgraded_creds.expires_in == expires_in # pylint: disable=no-member 98 | assert upgraded_creds.token_type == creds1.token_type 99 | assert upgraded_creds.refresh_token == creds1.refresh_token 100 | assert upgraded_creds.userid == creds1.userid 101 | assert upgraded_creds.client_id == creds1.client_id 102 | assert upgraded_creds.consumer_secret == creds1.consumer_secret 103 | assert upgraded_creds.created.format("YYYY-MM-DD HH:mm:ss") == arrow.get( 104 | creds1.token_expiry - expires_in 105 | ).format("YYYY-MM-DD HH:mm:ss") 106 | assert upgraded_creds.token_expiry == creds1.token_expiry 107 | 108 | 109 | def test_query_measure_groups() -> None: 110 | """Test function.""" 111 | response: Final = MeasureGetMeasResponse( 112 | offset=0, 113 | more=False, 114 | timezone=TIMEZONE0, 115 | updatetime=arrow.get(100000), 116 | measuregrps=( 117 | MeasureGetMeasGroup( 118 | attrib=MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 119 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 120 | created=arrow.get(10000200), 121 | date=arrow.get(10000300), 122 | deviceid="dev1", 123 | grpid=1, 124 | measures=( 125 | MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=1, value=10), 126 | MeasureGetMeasMeasure( 127 | type=MeasureType.BONE_MASS, unit=-2, value=20 128 | ), 129 | ), 130 | ), 131 | MeasureGetMeasGroup( 132 | attrib=MeasureGetMeasGroupAttrib.MEASURE_USER_CONFIRMED, 133 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 134 | created=arrow.get(10000400), 135 | date=arrow.get(10000500), 136 | deviceid="dev2", 137 | grpid=2, 138 | measures=( 139 | MeasureGetMeasMeasure( 140 | type=MeasureType.BONE_MASS, unit=21, value=210 141 | ), 142 | MeasureGetMeasMeasure( 143 | type=MeasureType.FAT_FREE_MASS, unit=-22, value=220 144 | ), 145 | ), 146 | ), 147 | ), 148 | ) 149 | 150 | # Measure type filter. 151 | expected1: Final = tuple( 152 | [ 153 | MeasureGetMeasGroup( 154 | attrib=MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 155 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 156 | created=arrow.get(10000200), 157 | date=arrow.get(10000300), 158 | deviceid="dev1", 159 | grpid=1, 160 | measures=(), 161 | ), 162 | MeasureGetMeasGroup( 163 | attrib=MeasureGetMeasGroupAttrib.MEASURE_USER_CONFIRMED, 164 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 165 | created=arrow.get(10000400), 166 | date=arrow.get(10000500), 167 | deviceid="dev2", 168 | grpid=2, 169 | measures=( 170 | MeasureGetMeasMeasure( 171 | type=MeasureType.FAT_FREE_MASS, unit=-22, value=220 172 | ), 173 | ), 174 | ), 175 | ] 176 | ) 177 | assert query_measure_groups(response, MeasureType.FAT_FREE_MASS) == expected1 178 | 179 | expected2: Final = tuple( 180 | [ 181 | MeasureGetMeasGroup( 182 | attrib=MeasureGetMeasGroupAttrib.MEASURE_USER_CONFIRMED, 183 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 184 | created=arrow.get(10000400), 185 | date=arrow.get(10000500), 186 | deviceid="dev2", 187 | grpid=2, 188 | measures=( 189 | MeasureGetMeasMeasure( 190 | type=MeasureType.FAT_FREE_MASS, unit=-22, value=220 191 | ), 192 | ), 193 | ) 194 | ] 195 | ) 196 | assert ( 197 | query_measure_groups( 198 | response, 199 | MeasureType.FAT_FREE_MASS, 200 | MeasureGetMeasGroupAttrib.MEASURE_USER_CONFIRMED, 201 | ) 202 | == expected2 203 | ) 204 | 205 | expected3: Final = tuple( 206 | [ 207 | MeasureGetMeasGroup( 208 | attrib=MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 209 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 210 | created=arrow.get(10000200), 211 | date=arrow.get(10000300), 212 | deviceid="dev1", 213 | grpid=1, 214 | measures=( 215 | MeasureGetMeasMeasure( 216 | type=MeasureType.BONE_MASS, unit=-2, value=20 217 | ), 218 | ), 219 | ), 220 | MeasureGetMeasGroup( 221 | attrib=MeasureGetMeasGroupAttrib.MEASURE_USER_CONFIRMED, 222 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 223 | created=arrow.get(10000400), 224 | date=arrow.get(10000500), 225 | deviceid="dev2", 226 | grpid=2, 227 | measures=( 228 | MeasureGetMeasMeasure( 229 | type=MeasureType.BONE_MASS, unit=21, value=210 230 | ), 231 | ), 232 | ), 233 | ] 234 | ) 235 | assert query_measure_groups(response, MeasureType.BONE_MASS) == expected3 236 | 237 | # Group attrib filter. 238 | assert query_measure_groups(response) == response.measuregrps 239 | 240 | assert ( 241 | query_measure_groups(response, MeasureTypes.ANY, MeasureGroupAttribs.ANY) 242 | == response.measuregrps 243 | ) 244 | 245 | assert query_measure_groups( 246 | response, MeasureTypes.ANY, MeasureGroupAttribs.AMBIGUOUS 247 | ) == (response.measuregrps[0],) 248 | 249 | assert query_measure_groups( 250 | response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS 251 | ) == (response.measuregrps[1],) 252 | 253 | assert query_measure_groups( 254 | response, MeasureTypes.ANY, response.measuregrps[0].attrib 255 | ) == (response.measuregrps[0],) 256 | 257 | 258 | def test_get_measure_value() -> None: 259 | """Test function.""" 260 | response: Final = MeasureGetMeasResponse( 261 | offset=0, 262 | more=False, 263 | timezone=TIMEZONE0, 264 | updatetime=arrow.get(100000), 265 | measuregrps=( 266 | MeasureGetMeasGroup( 267 | attrib=MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 268 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 269 | created=arrow.utcnow(), 270 | date=arrow.utcnow(), 271 | deviceid="dev1", 272 | grpid=1, 273 | measures=( 274 | MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=1, value=10), 275 | MeasureGetMeasMeasure( 276 | type=MeasureType.BONE_MASS, unit=-2, value=20 277 | ), 278 | ), 279 | ), 280 | ), 281 | ) 282 | 283 | assert get_measure_value(response, MeasureType.BODY_TEMPERATURE) is None 284 | 285 | assert get_measure_value(response.measuregrps, MeasureType.BODY_TEMPERATURE) is None 286 | 287 | assert ( 288 | get_measure_value(response.measuregrps[0], MeasureType.BODY_TEMPERATURE) is None 289 | ) 290 | 291 | assert get_measure_value(response, MeasureType.WEIGHT) == 100 292 | assert get_measure_value(response.measuregrps, MeasureType.WEIGHT) == 100 293 | assert get_measure_value(response.measuregrps[0], MeasureType.WEIGHT) == 100 294 | 295 | assert get_measure_value(response, MeasureType.BONE_MASS) == 0.2 296 | assert get_measure_value(response.measuregrps, MeasureType.BONE_MASS) == 0.2 297 | assert get_measure_value(response.measuregrps[0], MeasureType.BONE_MASS) == 0.2 298 | 299 | 300 | def response_status_factory(status: Any) -> Dict[str, Any]: 301 | """Return mock response.""" 302 | return {"status": status, "body": {}} 303 | 304 | 305 | def test_response_body_or_raise() -> None: 306 | """Test function.""" 307 | with pytest.raises(UnexpectedTypeException): 308 | response_body_or_raise("hello") 309 | 310 | with pytest.raises(UnknownStatusException): 311 | response_body_or_raise(response_status_factory("hello")) 312 | 313 | with pytest.raises(UnknownStatusException): 314 | response_body_or_raise(response_status_factory(None)) 315 | 316 | for status in STATUS_SUCCESS: 317 | response_body_or_raise(response_status_factory(status)) 318 | 319 | for status in STATUS_AUTH_FAILED: 320 | with pytest.raises(AuthFailedException): 321 | response_body_or_raise(response_status_factory(status)) 322 | 323 | for status in STATUS_INVALID_PARAMS: 324 | with pytest.raises(InvalidParamsException): 325 | response_body_or_raise(response_status_factory(status)) 326 | 327 | for status in STATUS_UNAUTHORIZED: 328 | with pytest.raises(UnauthorizedException): 329 | response_body_or_raise(response_status_factory(status)) 330 | 331 | for status in STATUS_ERROR_OCCURRED: 332 | with pytest.raises(ErrorOccurredException): 333 | response_body_or_raise(response_status_factory(status)) 334 | 335 | for status in STATUS_TIMEOUT: 336 | with pytest.raises(TimeoutException): 337 | response_body_or_raise(response_status_factory(status)) 338 | 339 | for status in STATUS_BAD_STATE: 340 | with pytest.raises(BadStateException): 341 | response_body_or_raise(response_status_factory(status)) 342 | 343 | for status in STATUS_TOO_MANY_REQUESTS: 344 | with pytest.raises(TooManyRequestsException): 345 | response_body_or_raise(response_status_factory(status)) 346 | 347 | with pytest.raises(UnknownStatusException): 348 | response_body_or_raise(response_status_factory(100000)) 349 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Tets for main API.""" 2 | import datetime 3 | import re 4 | from unittest.mock import MagicMock 5 | from urllib import parse 6 | 7 | import arrow 8 | import pytest 9 | import responses 10 | from typing_extensions import Final 11 | from withings_api import WithingsApi, WithingsAuth 12 | from withings_api.common import ( 13 | AfibClassification, 14 | AuthScope, 15 | Credentials2, 16 | GetActivityField, 17 | GetSleepField, 18 | GetSleepSummaryData, 19 | GetSleepSummaryField, 20 | GetSleepSummarySerie, 21 | HeartBloodPressure, 22 | HeartGetResponse, 23 | HeartListECG, 24 | HeartListResponse, 25 | HeartListSerie, 26 | HeartModel, 27 | HeartWearPosition, 28 | MeasureGetActivityActivity, 29 | MeasureGetActivityResponse, 30 | MeasureGetMeasGroup, 31 | MeasureGetMeasGroupAttrib, 32 | MeasureGetMeasGroupCategory, 33 | MeasureGetMeasMeasure, 34 | MeasureGetMeasResponse, 35 | MeasureType, 36 | NotifyAppli, 37 | NotifyGetResponse, 38 | NotifyListProfile, 39 | NotifyListResponse, 40 | SleepGetResponse, 41 | SleepGetSerie, 42 | SleepGetSummaryResponse, 43 | SleepGetTimestampValue, 44 | SleepModel, 45 | SleepState, 46 | UserGetDeviceDevice, 47 | UserGetDeviceResponse, 48 | ) 49 | 50 | from .common import TIMEZONE0, TIMEZONE1, TIMEZONE_STR0, TIMEZONE_STR1 51 | 52 | _UNKNOWN_INT = 1234567 53 | _USERID: Final = 12345 54 | _FETCH_TOKEN_RESPONSE_BODY: Final = { 55 | "status": 0, 56 | "body": { 57 | "access_token": "my_access_token", 58 | "csrf_token": "CSRF_TOKEN", 59 | "expires_in": 11, 60 | "token_type": "Bearer", 61 | "refresh_token": "my_refresh_token", 62 | "scope": "user.metrics,user.activity", 63 | "userid": _USERID, 64 | }, 65 | } 66 | 67 | 68 | @pytest.fixture(name="withings_api") 69 | def withings_api_instance() -> WithingsApi: 70 | """Test function.""" 71 | client_id: Final = "my_client_id" 72 | consumer_secret: Final = "my_consumer_secret" 73 | credentials: Final = Credentials2( 74 | access_token="my_access_token", 75 | expires_in=10000, 76 | token_type="Bearer", 77 | refresh_token="my_refresh_token", 78 | userid=_USERID, 79 | client_id=client_id, 80 | consumer_secret=consumer_secret, 81 | ) 82 | 83 | return WithingsApi(credentials) 84 | 85 | 86 | def test_get_authorize_url() -> None: 87 | """Test function.""" 88 | 89 | auth1: Final = WithingsAuth( 90 | client_id="fake_client_id", 91 | consumer_secret="fake_consumer_secret", 92 | callback_uri="http://localhost", 93 | ) 94 | 95 | auth2: Final = WithingsAuth( 96 | client_id="fake_client_id", 97 | consumer_secret="fake_consumer_secret", 98 | callback_uri="http://localhost", 99 | mode="MY_MODE", 100 | ) 101 | 102 | assert "&mode=MY_MODE" not in auth1.get_authorize_url() 103 | assert "&mode=MY_MODE" in auth2.get_authorize_url() 104 | 105 | 106 | @responses.activate 107 | def test_authorize() -> None: 108 | """Test function.""" 109 | client_id: Final = "fake_client_id" 110 | consumer_secret: Final = "fake_consumer_secret" 111 | callback_uri: Final = "http://127.0.0.1:8080" 112 | 113 | responses.add( 114 | method=responses.POST, 115 | url="https://wbsapi.withings.net/v2/oauth2", 116 | json=_FETCH_TOKEN_RESPONSE_BODY, 117 | status=200, 118 | ) 119 | 120 | auth: Final = WithingsAuth( 121 | client_id, 122 | consumer_secret, 123 | callback_uri=callback_uri, 124 | scope=(AuthScope.USER_METRICS, AuthScope.USER_ACTIVITY), 125 | ) 126 | 127 | url: Final = auth.get_authorize_url() 128 | 129 | assert url.startswith("https://account.withings.com/oauth2_user/authorize2") 130 | 131 | assert_url_query_equals( 132 | url, 133 | { 134 | "response_type": "code", 135 | "client_id": "fake_client_id", 136 | "redirect_uri": "http://127.0.0.1:8080", 137 | "scope": "user.metrics,user.activity", 138 | }, 139 | ) 140 | 141 | params: Final = dict(parse.parse_qsl(parse.urlsplit(url).query)) 142 | assert "scope" in params 143 | assert len(params["scope"]) > 0 144 | 145 | creds: Final = auth.get_credentials("FAKE_CODE") 146 | 147 | assert creds.access_token == "my_access_token" 148 | assert creds.token_type == "Bearer" 149 | assert creds.refresh_token == "my_refresh_token" 150 | assert creds.userid == _USERID 151 | assert creds.client_id == client_id 152 | assert creds.consumer_secret == consumer_secret 153 | assert creds.expires_in == 11 154 | assert creds.token_expiry == arrow.utcnow().int_timestamp + 11 155 | 156 | 157 | @responses.activate 158 | def test_refresh_token() -> None: 159 | """Test function.""" 160 | client_id: Final = "my_client_id" 161 | consumer_secret: Final = "my_consumer_secret" 162 | 163 | credentials: Final = Credentials2( 164 | access_token="my_access_token,_old", 165 | expires_in=-1, 166 | token_type="Bearer", 167 | refresh_token="my_refresh_token_old", 168 | userid=_USERID, 169 | client_id=client_id, 170 | consumer_secret=consumer_secret, 171 | ) 172 | 173 | responses.add( 174 | method=responses.POST, 175 | url=re.compile("https://wbsapi.withings.net/v2/oauth2.*"), 176 | status=200, 177 | json=_FETCH_TOKEN_RESPONSE_BODY, 178 | ) 179 | responses.add( 180 | method=responses.POST, 181 | url=re.compile("https://wbsapi.withings.net/v2/oauth2.*"), 182 | status=200, 183 | json={ 184 | "body": { 185 | "access_token": "my_access_token_refreshed", 186 | "expires_in": 11, 187 | "token_type": "Bearer", 188 | "refresh_token": "my_refresh_token_refreshed", 189 | "userid": _USERID, 190 | }, 191 | }, 192 | ) 193 | 194 | responses_add_measure_get_activity() 195 | 196 | refresh_callback: Final = MagicMock() 197 | api: Final = WithingsApi(credentials, refresh_callback) 198 | api.measure_get_activity() 199 | 200 | refresh_callback.assert_called_with(api.get_credentials()) 201 | new_credentials1: Final = api.get_credentials() 202 | assert new_credentials1.access_token == "my_access_token" 203 | assert new_credentials1.refresh_token == "my_refresh_token" 204 | assert new_credentials1.token_expiry > credentials.token_expiry 205 | refresh_callback.reset_mock() 206 | 207 | api.refresh_token() 208 | refresh_callback.assert_called_with(api.get_credentials()) 209 | new_credentials2: Final = api.get_credentials() 210 | assert new_credentials2.access_token == "my_access_token_refreshed" 211 | assert new_credentials2.refresh_token == "my_refresh_token_refreshed" 212 | assert new_credentials2.token_expiry > credentials.token_expiry 213 | 214 | 215 | def responses_add_user_get_device() -> None: 216 | """Set up request response.""" 217 | responses.add( 218 | method=responses.GET, 219 | url=re.compile("https://wbsapi.withings.net/v2/user?.*action=getdevice(&.*)?"), 220 | status=200, 221 | json={ 222 | "status": 0, 223 | "body": { 224 | "devices": [ 225 | { 226 | "type": "type0", 227 | "model": "model0", 228 | "battery": "battery0", 229 | "deviceid": "deviceid0", 230 | "timezone": TIMEZONE_STR0, 231 | }, 232 | { 233 | "type": "type1", 234 | "model": "model1", 235 | "battery": "battery1", 236 | "deviceid": "deviceid1", 237 | "timezone": TIMEZONE_STR1, 238 | }, 239 | ] 240 | }, 241 | }, 242 | ) 243 | 244 | 245 | @responses.activate 246 | def test_user_get_device(withings_api: WithingsApi) -> None: 247 | """Test function.""" 248 | responses_add_user_get_device() 249 | assert withings_api.user_get_device() == UserGetDeviceResponse( 250 | devices=( 251 | UserGetDeviceDevice( 252 | type="type0", 253 | model="model0", 254 | battery="battery0", 255 | deviceid="deviceid0", 256 | timezone=TIMEZONE0, 257 | ), 258 | UserGetDeviceDevice( 259 | type="type1", 260 | model="model1", 261 | battery="battery1", 262 | deviceid="deviceid1", 263 | timezone=TIMEZONE1, 264 | ), 265 | ) 266 | ) 267 | 268 | assert_url_path(responses.calls[0].request.url, "/v2/user") 269 | assert_url_query_equals(responses.calls[0].request.url, {"action": "getdevice"}) 270 | 271 | 272 | def responses_add_measure_get_activity() -> None: 273 | """Set up request response.""" 274 | responses.add( 275 | method=responses.GET, 276 | url=re.compile( 277 | "https://wbsapi.withings.net/v2/measure?.*action=getactivity(&.*)?" 278 | ), 279 | status=200, 280 | json={ 281 | "status": 0, 282 | "body": { 283 | "more": False, 284 | "offset": 0, 285 | "activities": [ 286 | { 287 | "date": "2019-01-01", 288 | "timezone": TIMEZONE_STR0, 289 | "is_tracker": True, 290 | "deviceid": "dev1", 291 | "brand": 100, 292 | "steps": 101, 293 | "distance": 102.1, 294 | "elevation": 103.1, 295 | "soft": 104, 296 | "moderate": 105, 297 | "intense": 106, 298 | "active": 107, 299 | "calories": 108.1, 300 | "totalcalories": 109.1, 301 | "hr_average": 110, 302 | "hr_min": 111, 303 | "hr_max": 112, 304 | "hr_zone_0": 113, 305 | "hr_zone_1": 114, 306 | "hr_zone_2": 115, 307 | "hr_zone_3": 116, 308 | }, 309 | { 310 | "date": "2019-01-02", 311 | "timezone": TIMEZONE_STR1, 312 | "is_tracker": False, 313 | "deviceid": "dev2", 314 | "brand": 200, 315 | "steps": 201, 316 | "distance": 202.1, 317 | "elevation": 203.1, 318 | "soft": 204, 319 | "moderate": 205, 320 | "intense": 206, 321 | "active": 207, 322 | "calories": 208.1, 323 | "totalcalories": 209.1, 324 | "hr_average": 210, 325 | "hr_min": 211, 326 | "hr_max": 212, 327 | "hr_zone_0": 213, 328 | "hr_zone_1": 214, 329 | "hr_zone_2": 215, 330 | "hr_zone_3": 216, 331 | }, 332 | ], 333 | }, 334 | }, 335 | ) 336 | 337 | 338 | @responses.activate 339 | def test_measure_get_activity(withings_api: WithingsApi) -> None: 340 | """Test function.""" 341 | responses_add_measure_get_activity() 342 | assert withings_api.measure_get_activity() == MeasureGetActivityResponse( 343 | more=False, 344 | offset=0, 345 | activities=( 346 | MeasureGetActivityActivity( 347 | date=arrow.get("2019-01-01").to(TIMEZONE0), 348 | timezone=TIMEZONE0, 349 | is_tracker=True, 350 | deviceid="dev1", 351 | brand=100, 352 | steps=101, 353 | distance=102.1, 354 | elevation=103.1, 355 | soft=104, 356 | moderate=105, 357 | intense=106, 358 | active=107, 359 | calories=108.1, 360 | totalcalories=109.1, 361 | hr_average=110, 362 | hr_min=111, 363 | hr_max=112, 364 | hr_zone_0=113, 365 | hr_zone_1=114, 366 | hr_zone_2=115, 367 | hr_zone_3=116, 368 | ), 369 | MeasureGetActivityActivity( 370 | date=arrow.get("2019-01-02").to(TIMEZONE1), 371 | timezone=TIMEZONE1, 372 | is_tracker=False, 373 | deviceid="dev2", 374 | brand=200, 375 | steps=201, 376 | distance=202.1, 377 | elevation=203.1, 378 | soft=204, 379 | moderate=205, 380 | intense=206, 381 | active=207, 382 | calories=208.1, 383 | totalcalories=209.1, 384 | hr_average=210, 385 | hr_min=211, 386 | hr_max=212, 387 | hr_zone_0=213, 388 | hr_zone_1=214, 389 | hr_zone_2=215, 390 | hr_zone_3=216, 391 | ), 392 | ), 393 | ) 394 | 395 | 396 | def responses_add_measure_get_meas() -> None: 397 | """Set up request response.""" 398 | responses.add( 399 | method=responses.GET, 400 | url=re.compile("https://wbsapi.withings.net/measure?.*action=getmeas(&.*)?"), 401 | status=200, 402 | json={ 403 | "status": 0, 404 | "body": { 405 | "more": False, 406 | "offset": 0, 407 | "updatetime": 1409596058, 408 | "timezone": TIMEZONE_STR0, 409 | "measuregrps": [ 410 | { 411 | "attrib": MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 412 | "category": MeasureGetMeasGroupCategory.REAL, 413 | "created": 1111111111, 414 | "date": "2019-01-01", 415 | "deviceid": "dev1", 416 | "grpid": 1, 417 | "measures": [ 418 | {"type": MeasureType.HEIGHT, "unit": 110, "value": 110}, 419 | {"type": MeasureType.WEIGHT, "unit": 120, "value": 120}, 420 | ], 421 | }, 422 | { 423 | "attrib": MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, 424 | "category": MeasureGetMeasGroupCategory.USER_OBJECTIVES, 425 | "created": 2222222222, 426 | "date": "2019-01-02", 427 | "deviceid": "dev2", 428 | "grpid": 2, 429 | "measures": [ 430 | { 431 | "type": MeasureType.BODY_TEMPERATURE, 432 | "unit": 210, 433 | "value": 210, 434 | }, 435 | {"type": MeasureType.BONE_MASS, "unit": 220, "value": 220}, 436 | ], 437 | }, 438 | { 439 | "attrib": _UNKNOWN_INT, 440 | "category": _UNKNOWN_INT, 441 | "created": 2222222222, 442 | "date": "2019-01-02", 443 | "deviceid": "dev2", 444 | "grpid": 2, 445 | "measures": [{"type": _UNKNOWN_INT, "unit": 230, "value": 230}], 446 | }, 447 | ], 448 | }, 449 | }, 450 | ) 451 | 452 | 453 | @responses.activate 454 | def test_measure_get_meas(withings_api: WithingsApi) -> None: 455 | """Test function.""" 456 | responses_add_measure_get_meas() 457 | assert withings_api.measure_get_meas() == MeasureGetMeasResponse( 458 | more=False, 459 | offset=0, 460 | timezone=TIMEZONE0, 461 | updatetime=arrow.get(1409596058).to(TIMEZONE0), 462 | measuregrps=( 463 | MeasureGetMeasGroup( 464 | attrib=MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 465 | category=MeasureGetMeasGroupCategory.REAL, 466 | created=arrow.get(1111111111).to(TIMEZONE0), 467 | date=arrow.get("2019-01-01").to(TIMEZONE0), 468 | deviceid="dev1", 469 | grpid=1, 470 | measures=( 471 | MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=110, value=110), 472 | MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=120, value=120), 473 | ), 474 | ), 475 | MeasureGetMeasGroup( 476 | attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, 477 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 478 | created=arrow.get(2222222222).to(TIMEZONE0), 479 | date=arrow.get("2019-01-02").to(TIMEZONE0), 480 | deviceid="dev2", 481 | grpid=2, 482 | measures=( 483 | MeasureGetMeasMeasure( 484 | type=MeasureType.BODY_TEMPERATURE, unit=210, value=210 485 | ), 486 | MeasureGetMeasMeasure( 487 | type=MeasureType.BONE_MASS, unit=220, value=220 488 | ), 489 | ), 490 | ), 491 | MeasureGetMeasGroup( 492 | attrib=MeasureGetMeasGroupAttrib.UNKNOWN, 493 | category=MeasureGetMeasGroupCategory.UNKNOWN, 494 | created=arrow.get(2222222222).to(TIMEZONE0), 495 | date=arrow.get("2019-01-02").to(TIMEZONE0), 496 | deviceid="dev2", 497 | grpid=2, 498 | measures=( 499 | MeasureGetMeasMeasure( 500 | type=MeasureType.UNKNOWN, unit=230, value=230 501 | ), 502 | ), 503 | ), 504 | ), 505 | ) 506 | 507 | 508 | def responses_add_sleep_get(root_model: int) -> None: 509 | """Set up request response.""" 510 | responses.add( 511 | method=responses.GET, 512 | url=re.compile("https://wbsapi.withings.net/v2/sleep?.*action=get(&.+)?"), 513 | status=200, 514 | json={ 515 | "status": 0, 516 | "body": { 517 | "series": [ 518 | { 519 | "startdate": 1387235398, 520 | "state": SleepState.AWAKE, 521 | "enddate": 1387235758, 522 | }, 523 | { 524 | "startdate": 1387243618, 525 | "state": SleepState.LIGHT, 526 | "enddate": 1387244518, 527 | "hr": {"1387243618": 12, "1387243700": 34}, 528 | "rr": {"1387243618": 45, "1387243700": 67}, 529 | "snoring": {"1387243618": 78, "1387243700": 90}, 530 | }, 531 | { 532 | "startdate": 1387235398, 533 | "state": _UNKNOWN_INT, 534 | "enddate": 1387235758, 535 | }, 536 | ], 537 | "model": root_model, 538 | }, 539 | }, 540 | ) 541 | 542 | 543 | @responses.activate 544 | def test_sleep_get_known(withings_api: WithingsApi) -> None: 545 | """Test function.""" 546 | responses_add_sleep_get(SleepModel.TRACKER) 547 | assert withings_api.sleep_get(data_fields=GetSleepField) == SleepGetResponse( 548 | model=SleepModel.TRACKER, 549 | series=( 550 | SleepGetSerie( 551 | startdate=arrow.get(1387235398), 552 | state=SleepState.AWAKE, 553 | enddate=arrow.get(1387235758), 554 | hr=(), 555 | rr=(), 556 | snoring=(), 557 | ), 558 | SleepGetSerie( 559 | startdate=arrow.get(1387243618), 560 | state=SleepState.LIGHT, 561 | enddate=arrow.get(1387244518), 562 | hr=( 563 | SleepGetTimestampValue(timestamp=arrow.get(1387243618), value=12), 564 | SleepGetTimestampValue(timestamp=arrow.get(1387243700), value=34), 565 | ), 566 | rr=( 567 | SleepGetTimestampValue(timestamp=arrow.get(1387243618), value=45), 568 | SleepGetTimestampValue(timestamp=arrow.get(1387243700), value=67), 569 | ), 570 | snoring=( 571 | SleepGetTimestampValue(timestamp=arrow.get(1387243618), value=78), 572 | SleepGetTimestampValue(timestamp=arrow.get(1387243700), value=90), 573 | ), 574 | ), 575 | SleepGetSerie( 576 | startdate=arrow.get(1387235398), 577 | state=SleepState.UNKNOWN, 578 | enddate=arrow.get(1387235758), 579 | hr=(), 580 | rr=(), 581 | snoring=(), 582 | ), 583 | ), 584 | ) 585 | 586 | 587 | @responses.activate 588 | def test_sleep_get_unknown(withings_api: WithingsApi) -> None: 589 | """Test function.""" 590 | responses_add_sleep_get(_UNKNOWN_INT) 591 | assert withings_api.sleep_get(data_fields=GetSleepField) == SleepGetResponse( 592 | model=SleepModel.UNKNOWN, 593 | series=( 594 | SleepGetSerie( 595 | startdate=arrow.get(1387235398), 596 | state=SleepState.AWAKE, 597 | enddate=arrow.get(1387235758), 598 | hr=(), 599 | rr=(), 600 | snoring=(), 601 | ), 602 | SleepGetSerie( 603 | startdate=arrow.get(1387243618), 604 | state=SleepState.LIGHT, 605 | enddate=arrow.get(1387244518), 606 | hr=( 607 | SleepGetTimestampValue(timestamp=arrow.get(1387243618), value=12), 608 | SleepGetTimestampValue(timestamp=arrow.get(1387243700), value=34), 609 | ), 610 | rr=( 611 | SleepGetTimestampValue(timestamp=arrow.get(1387243618), value=45), 612 | SleepGetTimestampValue(timestamp=arrow.get(1387243700), value=67), 613 | ), 614 | snoring=( 615 | SleepGetTimestampValue(timestamp=arrow.get(1387243618), value=78), 616 | SleepGetTimestampValue(timestamp=arrow.get(1387243700), value=90), 617 | ), 618 | ), 619 | SleepGetSerie( 620 | startdate=arrow.get(1387235398), 621 | state=SleepState.UNKNOWN, 622 | enddate=arrow.get(1387235758), 623 | hr=(), 624 | rr=(), 625 | snoring=(), 626 | ), 627 | ), 628 | ) 629 | 630 | 631 | def responses_add_sleep_get_summary() -> None: 632 | """Set up request response.""" 633 | responses.add( 634 | method=responses.GET, 635 | url=re.compile( 636 | "https://wbsapi.withings.net/v2/sleep?.*action=getsummary(&.*)?" 637 | ), 638 | status=200, 639 | json={ 640 | "status": 0, 641 | "body": { 642 | "more": False, 643 | "offset": 1, 644 | "series": [ 645 | { 646 | "data": { 647 | "breathing_disturbances_intensity": 110, 648 | "deepsleepduration": 111, 649 | "durationtosleep": 112, 650 | "durationtowakeup": 113, 651 | "hr_average": 114, 652 | "hr_max": 115, 653 | "hr_min": 116, 654 | "lightsleepduration": 117, 655 | "remsleepduration": 118, 656 | "rr_average": 119, 657 | "rr_max": 120, 658 | "rr_min": 121, 659 | "sleep_score": 122, 660 | "snoring": 123, 661 | "snoringepisodecount": 124, 662 | "wakeupcount": 125, 663 | "wakeupduration": 126, 664 | }, 665 | "date": "2018-10-30", 666 | "enddate": 1540897020, 667 | "id": 900363515, 668 | "model": SleepModel.TRACKER, 669 | "modified": 1540897246, 670 | "startdate": 1540857420, 671 | "timezone": TIMEZONE_STR0, 672 | }, 673 | { 674 | "data": { 675 | "breathing_disturbances_intensity": 210, 676 | "deepsleepduration": 211, 677 | "durationtosleep": 212, 678 | "durationtowakeup": 213, 679 | "hr_average": 214, 680 | "hr_max": 215, 681 | "hr_min": 216, 682 | "lightsleepduration": 217, 683 | "remsleepduration": 218, 684 | "rr_average": 219, 685 | "rr_max": 220, 686 | "rr_min": 221, 687 | "sleep_score": 222, 688 | "snoring": 223, 689 | "snoringepisodecount": 224, 690 | "wakeupcount": 225, 691 | "wakeupduration": 226, 692 | }, 693 | "date": "2018-10-31", 694 | "enddate": 1540973400, 695 | "id": 901269807, 696 | "model": SleepModel.TRACKER, 697 | "modified": 1541020749, 698 | "startdate": 1540944960, 699 | "timezone": TIMEZONE_STR1, 700 | }, 701 | { 702 | "data": { 703 | "breathing_disturbances_intensity": 210, 704 | "deepsleepduration": 211, 705 | "durationtosleep": 212, 706 | "durationtowakeup": 213, 707 | "hr_average": 214, 708 | "hr_max": 215, 709 | "hr_min": 216, 710 | "lightsleepduration": 217, 711 | "remsleepduration": 218, 712 | "rr_average": 219, 713 | "rr_max": 220, 714 | "rr_min": 221, 715 | "sleep_score": 222, 716 | "snoring": 223, 717 | "snoringepisodecount": 224, 718 | "wakeupcount": 225, 719 | "wakeupduration": 226, 720 | }, 721 | "date": "2018-10-31", 722 | "enddate": 1540973400, 723 | "id": 901269807, 724 | "model": _UNKNOWN_INT, 725 | "modified": 1541020749, 726 | "startdate": 1540944960, 727 | "timezone": TIMEZONE_STR1, 728 | }, 729 | ], 730 | }, 731 | }, 732 | ) 733 | 734 | 735 | @responses.activate 736 | def test_sleep_get_summary(withings_api: WithingsApi) -> None: 737 | """Test function.""" 738 | responses_add_sleep_get_summary() 739 | assert withings_api.sleep_get_summary( 740 | data_fields=GetSleepSummaryField 741 | ) == SleepGetSummaryResponse( 742 | more=False, 743 | offset=1, 744 | series=( 745 | GetSleepSummarySerie( 746 | date=arrow.get("2018-10-30").to(TIMEZONE0), 747 | enddate=arrow.get(1540897020).to(TIMEZONE0), 748 | model=SleepModel.TRACKER, 749 | modified=arrow.get(1540897246).to(TIMEZONE0), 750 | startdate=arrow.get(1540857420).to(TIMEZONE0), 751 | timezone=TIMEZONE0, 752 | id=900363515, 753 | data=GetSleepSummaryData( 754 | breathing_disturbances_intensity=110, 755 | deepsleepduration=111, 756 | durationtosleep=112, 757 | durationtowakeup=113, 758 | hr_average=114, 759 | hr_max=115, 760 | hr_min=116, 761 | lightsleepduration=117, 762 | remsleepduration=118, 763 | rr_average=119, 764 | rr_max=120, 765 | rr_min=121, 766 | sleep_score=122, 767 | snoring=123, 768 | snoringepisodecount=124, 769 | wakeupcount=125, 770 | wakeupduration=126, 771 | ), 772 | ), 773 | GetSleepSummarySerie( 774 | date=arrow.get("2018-10-31").to(TIMEZONE1), 775 | enddate=arrow.get(1540973400).to(TIMEZONE1), 776 | model=SleepModel.TRACKER, 777 | modified=arrow.get(1541020749).to(TIMEZONE1), 778 | startdate=arrow.get(1540944960).to(TIMEZONE1), 779 | timezone=TIMEZONE1, 780 | id=901269807, 781 | data=GetSleepSummaryData( 782 | breathing_disturbances_intensity=210, 783 | deepsleepduration=211, 784 | durationtosleep=212, 785 | durationtowakeup=213, 786 | hr_average=214, 787 | hr_max=215, 788 | hr_min=216, 789 | lightsleepduration=217, 790 | remsleepduration=218, 791 | rr_average=219, 792 | rr_max=220, 793 | rr_min=221, 794 | sleep_score=222, 795 | snoring=223, 796 | snoringepisodecount=224, 797 | wakeupcount=225, 798 | wakeupduration=226, 799 | ), 800 | ), 801 | GetSleepSummarySerie( 802 | date=arrow.get("2018-10-31").to(TIMEZONE1), 803 | enddate=arrow.get(1540973400).to(TIMEZONE1), 804 | model=SleepModel.UNKNOWN, 805 | modified=arrow.get(1541020749).to(TIMEZONE1), 806 | startdate=arrow.get(1540944960).to(TIMEZONE1), 807 | timezone=TIMEZONE1, 808 | id=901269807, 809 | data=GetSleepSummaryData( 810 | breathing_disturbances_intensity=210, 811 | deepsleepduration=211, 812 | durationtosleep=212, 813 | durationtowakeup=213, 814 | hr_average=214, 815 | hr_max=215, 816 | hr_min=216, 817 | lightsleepduration=217, 818 | remsleepduration=218, 819 | rr_average=219, 820 | rr_max=220, 821 | rr_min=221, 822 | sleep_score=222, 823 | snoring=223, 824 | snoringepisodecount=224, 825 | wakeupcount=225, 826 | wakeupduration=226, 827 | ), 828 | ), 829 | ), 830 | ) 831 | 832 | 833 | def responses_add_heart_get(wear_potion: int) -> None: 834 | """Set up request response.""" 835 | responses.add( 836 | method=responses.GET, 837 | url=re.compile("https://wbsapi.withings.net/v2/heart?.*action=get(&.*)?"), 838 | status=200, 839 | json={ 840 | "status": 0, 841 | "body": { 842 | "signal": [-20, 0, 20], 843 | "sampling_frequency": 500, 844 | "wearposition": wear_potion, 845 | }, 846 | }, 847 | ) 848 | 849 | 850 | @responses.activate 851 | def test_heart_get_known(withings_api: WithingsApi) -> None: 852 | """Test function.""" 853 | responses_add_heart_get(HeartWearPosition.LEFT_ARM.real) 854 | assert withings_api.heart_get(123456) == HeartGetResponse( 855 | signal=tuple([-20, 0, 20]), 856 | sampling_frequency=500, 857 | wearposition=HeartWearPosition.LEFT_ARM, 858 | ) 859 | 860 | 861 | @responses.activate 862 | def test_heart_get_unknown(withings_api: WithingsApi) -> None: 863 | """Test function.""" 864 | responses_add_heart_get(_UNKNOWN_INT) 865 | assert withings_api.heart_get(123456) == HeartGetResponse( 866 | signal=tuple([-20, 0, 20]), 867 | sampling_frequency=500, 868 | wearposition=HeartWearPosition.UNKNOWN, 869 | ) 870 | 871 | 872 | def responses_add_heart_list() -> None: 873 | """Set up request response.""" 874 | responses.add( 875 | method=responses.GET, 876 | url=re.compile("https://wbsapi.withings.net/v2/heart?.*action=list(&.*)?"), 877 | status=200, 878 | json={ 879 | "status": 0, 880 | "body": { 881 | "series": [ 882 | { 883 | "deviceid": "0123456789abcdef0123456789abcdef01234567", 884 | "model": HeartModel.BPM_CORE.real, 885 | "ecg": { 886 | "signalid": 9876543, 887 | "afib": AfibClassification.NEGATIVE.real, 888 | }, 889 | "bloodpressure": {"diastole": 80, "systole": 120}, 890 | "heart_rate": 78, 891 | "timestamp": 1594911107, 892 | }, 893 | { 894 | "deviceid": "0123456789abcdef0123456789abcdef01234567", 895 | "model": HeartModel.BPM_CORE.real, 896 | "ecg": { 897 | "signalid": 7654321, 898 | "afib": AfibClassification.POSITIVE.real, 899 | }, 900 | "bloodpressure": {"diastole": 75, "systole": 125}, 901 | "heart_rate": 87, 902 | "timestamp": 1594910902, 903 | }, 904 | # the Move ECG device does not take blood pressure, leave it out here 905 | { 906 | "deviceid": "abcdef0123456789abcdef012345670123456789", 907 | "model": HeartModel.MOVE_ECG.real, 908 | "ecg": { 909 | "signalid": 123987, 910 | "afib": AfibClassification.INCONCLUSIVE.real, 911 | }, 912 | "heart_rate": 77, 913 | "timestamp": 1594921551, 914 | }, 915 | { 916 | "deviceid": "abcdef0123456789abcdef012345670123456789", 917 | "model": _UNKNOWN_INT, 918 | "ecg": {"signalid": 123987, "afib": _UNKNOWN_INT}, 919 | "heart_rate": 77, 920 | "timestamp": 1594921551, 921 | }, 922 | ], 923 | "more": False, 924 | "offset": 0, 925 | }, 926 | }, 927 | ) 928 | 929 | 930 | @responses.activate 931 | def test_heart_list(withings_api: WithingsApi) -> None: 932 | """Test function.""" 933 | responses_add_heart_list() 934 | assert withings_api.heart_list() == HeartListResponse( 935 | more=False, 936 | offset=0, 937 | series=( 938 | HeartListSerie( 939 | deviceid="0123456789abcdef0123456789abcdef01234567", 940 | ecg=HeartListECG(signalid=9876543, afib=AfibClassification.NEGATIVE), 941 | bloodpressure=HeartBloodPressure(diastole=80, systole=120), 942 | heart_rate=78, 943 | timestamp=arrow.get(1594911107), 944 | model=HeartModel.BPM_CORE, 945 | ), 946 | HeartListSerie( 947 | deviceid="0123456789abcdef0123456789abcdef01234567", 948 | ecg=HeartListECG(signalid=7654321, afib=AfibClassification.POSITIVE), 949 | bloodpressure=HeartBloodPressure(diastole=75, systole=125), 950 | heart_rate=87, 951 | timestamp=arrow.get(1594910902), 952 | model=HeartModel.BPM_CORE, 953 | ), 954 | # the Move ECG device does not take blood pressure 955 | HeartListSerie( 956 | deviceid="abcdef0123456789abcdef012345670123456789", 957 | ecg=HeartListECG(signalid=123987, afib=AfibClassification.INCONCLUSIVE), 958 | bloodpressure=None, 959 | heart_rate=77, 960 | timestamp=arrow.get(1594921551), 961 | model=HeartModel.MOVE_ECG, 962 | ), 963 | HeartListSerie( 964 | deviceid="abcdef0123456789abcdef012345670123456789", 965 | ecg=HeartListECG(signalid=123987, afib=AfibClassification.UNKNOWN), 966 | bloodpressure=None, 967 | heart_rate=77, 968 | timestamp=arrow.get(1594921551), 969 | model=HeartModel.UNKNOWN, 970 | ), 971 | ), 972 | ) 973 | 974 | 975 | def responses_add_notify_get(appli: int) -> None: 976 | """Set up request response.""" 977 | responses.add( 978 | method=responses.GET, 979 | url=re.compile("https://wbsapi.withings.net/notify?.*action=get(&.*)?"), 980 | status=200, 981 | json={ 982 | "status": 0, 983 | "body": { 984 | "callbackurl": "http://localhost/callback", 985 | "appli": appli, 986 | "comment": "comment1", 987 | }, 988 | }, 989 | ) 990 | 991 | 992 | @responses.activate 993 | def test_notify_get_known(withings_api: WithingsApi) -> None: 994 | """Test function.""" 995 | responses_add_notify_get(NotifyAppli.ACTIVITY.real) 996 | 997 | response = withings_api.notify_get(callbackurl="http://localhost/callback") 998 | assert response == NotifyGetResponse( 999 | callbackurl="http://localhost/callback", 1000 | appli=NotifyAppli.ACTIVITY, 1001 | comment="comment1", 1002 | ) 1003 | 1004 | 1005 | @responses.activate 1006 | def test_notify_get_unknown(withings_api: WithingsApi) -> None: 1007 | """Test function.""" 1008 | responses_add_notify_get(_UNKNOWN_INT) 1009 | 1010 | response = withings_api.notify_get(callbackurl="http://localhost/callback") 1011 | assert response == NotifyGetResponse( 1012 | callbackurl="http://localhost/callback", 1013 | appli=NotifyAppli.UNKNOWN, 1014 | comment="comment1", 1015 | ) 1016 | 1017 | 1018 | def responses_add_notify_list() -> None: 1019 | """Set up request response.""" 1020 | responses.add( 1021 | method=responses.GET, 1022 | url=re.compile("https://wbsapi.withings.net/notify?.*action=list(&.*)?"), 1023 | status=200, 1024 | json={ 1025 | "status": 0, 1026 | "body": { 1027 | "profiles": [ 1028 | { 1029 | "appli": NotifyAppli.WEIGHT.real, 1030 | "callbackurl": "http://localhost/callback", 1031 | "comment": "fake_comment1", 1032 | "expires": None, 1033 | }, 1034 | { 1035 | "appli": NotifyAppli.CIRCULATORY.real, 1036 | "callbackurl": "http://localhost/callback2", 1037 | "comment": "fake_comment2", 1038 | "expires": "2019-09-02", 1039 | }, 1040 | { 1041 | "appli": 1234567, 1042 | "callbackurl": "http://localhost/callback2", 1043 | "comment": "fake_comment2", 1044 | "expires": "2019-09-02", 1045 | }, 1046 | ] 1047 | }, 1048 | }, 1049 | ) 1050 | 1051 | 1052 | @responses.activate 1053 | def test_notify_list(withings_api: WithingsApi) -> None: 1054 | """Test function.""" 1055 | responses_add_notify_list() 1056 | 1057 | assert withings_api.notify_list() == NotifyListResponse( 1058 | profiles=( 1059 | NotifyListProfile( 1060 | appli=NotifyAppli.WEIGHT, 1061 | callbackurl="http://localhost/callback", 1062 | comment="fake_comment1", 1063 | expires=None, 1064 | ), 1065 | NotifyListProfile( 1066 | appli=NotifyAppli.CIRCULATORY, 1067 | callbackurl="http://localhost/callback2", 1068 | comment="fake_comment2", 1069 | expires=arrow.get("2019-09-02"), 1070 | ), 1071 | NotifyListProfile( 1072 | appli=NotifyAppli.UNKNOWN, 1073 | callbackurl="http://localhost/callback2", 1074 | comment="fake_comment2", 1075 | expires=arrow.get("2019-09-02"), 1076 | ), 1077 | ) 1078 | ) 1079 | 1080 | assert_url_path(responses.calls[0].request.url, "/notify") 1081 | assert_url_query_equals(responses.calls[0].request.url, {"action": "list"}) 1082 | 1083 | 1084 | @responses.activate 1085 | def test_notify_get_params(withings_api: WithingsApi) -> None: 1086 | """Test function.""" 1087 | responses_add_notify_get(NotifyAppli.CIRCULATORY.real) 1088 | withings_api.notify_get( 1089 | callbackurl="http://localhost/callback2", appli=NotifyAppli.CIRCULATORY 1090 | ) 1091 | 1092 | assert_url_query_equals( 1093 | responses.calls[0].request.url, 1094 | { 1095 | "callbackurl": "http://localhost/callback2", 1096 | "appli": str(NotifyAppli.CIRCULATORY.real), 1097 | }, 1098 | ) 1099 | 1100 | assert_url_path(responses.calls[0].request.url, "/notify") 1101 | assert_url_query_equals(responses.calls[0].request.url, {"action": "get"}) 1102 | 1103 | 1104 | @responses.activate 1105 | def test_notify_list_params(withings_api: WithingsApi) -> None: 1106 | """Test function.""" 1107 | responses_add_notify_list() 1108 | withings_api.notify_list(appli=NotifyAppli.CIRCULATORY) 1109 | 1110 | assert_url_query_equals( 1111 | responses.calls[0].request.url, {"appli": str(NotifyAppli.CIRCULATORY.real)} 1112 | ) 1113 | 1114 | assert_url_path(responses.calls[0].request.url, "/notify") 1115 | assert_url_query_equals(responses.calls[0].request.url, {"action": "list"}) 1116 | 1117 | 1118 | def responses_add_notify_revoke() -> None: 1119 | """Test function.""" 1120 | responses.add( 1121 | method=responses.GET, 1122 | url=re.compile("https://wbsapi.withings.net/notify?.*action=revoke(&.*)?"), 1123 | status=200, 1124 | json={"status": 0, "body": {}}, 1125 | ) 1126 | 1127 | 1128 | @responses.activate 1129 | def test_notify_revoke_params(withings_api: WithingsApi) -> None: 1130 | """Test function.""" 1131 | responses_add_notify_revoke() 1132 | withings_api.notify_revoke(appli=NotifyAppli.CIRCULATORY) 1133 | 1134 | assert_url_query_equals( 1135 | responses.calls[0].request.url, {"appli": str(NotifyAppli.CIRCULATORY.real)} 1136 | ) 1137 | 1138 | assert_url_path(responses.calls[0].request.url, "/notify") 1139 | assert_url_query_equals(responses.calls[0].request.url, {"action": "revoke"}) 1140 | 1141 | 1142 | def responses_add_notify_subscribe() -> None: 1143 | """Test function.""" 1144 | responses.add( 1145 | method=responses.GET, 1146 | url=re.compile("https://wbsapi.withings.net/notify?.*action=subscribe(&.*)?"), 1147 | status=200, 1148 | json={"status": 0, "body": {}}, 1149 | ) 1150 | 1151 | 1152 | @responses.activate 1153 | def test_notify_subscribe_params(withings_api: WithingsApi) -> None: 1154 | """Test function.""" 1155 | responses_add_notify_subscribe() 1156 | withings_api.notify_subscribe( 1157 | callbackurl="http://localhost/callback2", 1158 | appli=NotifyAppli.CIRCULATORY, 1159 | comment="comment2", 1160 | ) 1161 | 1162 | assert_url_query_equals( 1163 | responses.calls[0].request.url, 1164 | { 1165 | "callbackurl": "http://localhost/callback2", 1166 | "appli": str(NotifyAppli.CIRCULATORY.real), 1167 | "comment": "comment2", 1168 | }, 1169 | ) 1170 | 1171 | assert_url_path(responses.calls[0].request.url, "/notify") 1172 | assert_url_query_equals(responses.calls[0].request.url, {"action": "subscribe"}) 1173 | 1174 | 1175 | def responses_add_notify_update() -> None: 1176 | """Test function.""" 1177 | responses.add( 1178 | method=responses.GET, 1179 | url=re.compile("https://wbsapi.withings.net/notify?.*action=update(&.*)?"), 1180 | status=200, 1181 | json={"status": 0, "body": {}}, 1182 | ) 1183 | 1184 | 1185 | @responses.activate 1186 | def test_notify_update_params(withings_api: WithingsApi) -> None: 1187 | """Test function.""" 1188 | responses_add_notify_update() 1189 | withings_api.notify_update( 1190 | callbackurl="http://localhost/callback2", 1191 | appli=NotifyAppli.CIRCULATORY, 1192 | new_callbackurl="http://localhost/callback2", 1193 | new_appli=NotifyAppli.SLEEP, 1194 | comment="comment3", 1195 | ) 1196 | 1197 | assert_url_query_equals( 1198 | responses.calls[0].request.url, 1199 | { 1200 | "callbackurl": "http://localhost/callback2", 1201 | "appli": str(NotifyAppli.CIRCULATORY.real), 1202 | "new_callbackurl": "http://localhost/callback2", 1203 | "new_appli": str(NotifyAppli.SLEEP.real), 1204 | "comment": "comment3", 1205 | }, 1206 | ) 1207 | 1208 | assert_url_path(responses.calls[0].request.url, "/notify") 1209 | assert_url_query_equals(responses.calls[0].request.url, {"action": "update"}) 1210 | 1211 | 1212 | @responses.activate 1213 | def test_measure_get_meas_params(withings_api: WithingsApi) -> None: 1214 | """Test function.""" 1215 | responses_add_measure_get_meas() 1216 | withings_api.measure_get_meas( 1217 | meastype=MeasureType.BONE_MASS, 1218 | category=MeasureGetMeasGroupCategory.USER_OBJECTIVES, 1219 | startdate=arrow.get("2019-01-01"), 1220 | enddate=100000000, 1221 | offset=12, 1222 | lastupdate=datetime.date(2019, 1, 2), 1223 | ) 1224 | 1225 | assert_url_query_equals( 1226 | responses.calls[0].request.url, 1227 | { 1228 | "meastype": "88", 1229 | "category": "2", 1230 | "startdate": "1546300800", 1231 | "enddate": "100000000", 1232 | "offset": "12", 1233 | "lastupdate": "1546387200", 1234 | }, 1235 | ) 1236 | 1237 | assert_url_path(responses.calls[0].request.url, "/measure") 1238 | assert_url_query_equals(responses.calls[0].request.url, {"action": "getmeas"}) 1239 | 1240 | 1241 | @responses.activate 1242 | def test_measure_get_activity_params(withings_api: WithingsApi) -> None: 1243 | """Test function.""" 1244 | responses_add_measure_get_activity() 1245 | withings_api.measure_get_activity( 1246 | startdateymd="2019-01-01", 1247 | enddateymd=arrow.get("2019-01-02"), 1248 | offset=2, 1249 | data_fields=( 1250 | GetActivityField.ACTIVE, 1251 | GetActivityField.CALORIES, 1252 | GetActivityField.ELEVATION, 1253 | ), 1254 | lastupdate=10000000, 1255 | ) 1256 | 1257 | assert_url_query_equals( 1258 | responses.calls[0].request.url, 1259 | { 1260 | "startdateymd": "2019-01-01", 1261 | "enddateymd": "2019-01-02", 1262 | "offset": "2", 1263 | "data_fields": "active,calories,elevation", 1264 | "lastupdate": "10000000", 1265 | }, 1266 | ) 1267 | 1268 | assert_url_path(responses.calls[0].request.url, "/v2/measure") 1269 | assert_url_query_equals(responses.calls[0].request.url, {"action": "getactivity"}) 1270 | 1271 | 1272 | @responses.activate 1273 | def test_get_sleep_params(withings_api: WithingsApi) -> None: 1274 | """Test function.""" 1275 | responses_add_sleep_get(SleepModel.TRACKER) 1276 | withings_api.sleep_get( 1277 | startdate="2019-01-01", 1278 | enddate=arrow.get("2019-01-02"), 1279 | data_fields=(GetSleepField.HR, GetSleepField.HR), 1280 | ) 1281 | 1282 | assert_url_query_equals( 1283 | responses.calls[0].request.url, 1284 | {"startdate": "1546300800", "enddate": "1546387200", "data_fields": "hr,hr"}, 1285 | ) 1286 | 1287 | assert_url_path(responses.calls[0].request.url, "/v2/sleep") 1288 | assert_url_query_equals(responses.calls[0].request.url, {"action": "get"}) 1289 | 1290 | 1291 | @responses.activate 1292 | def test_get_sleep_summary_params(withings_api: WithingsApi) -> None: 1293 | """Test function.""" 1294 | responses_add_sleep_get_summary() 1295 | withings_api.sleep_get_summary( 1296 | startdateymd="2019-01-01", 1297 | enddateymd=arrow.get("2019-01-02"), 1298 | data_fields=( 1299 | GetSleepSummaryField.DEEP_SLEEP_DURATION, 1300 | GetSleepSummaryField.HR_AVERAGE, 1301 | ), 1302 | offset=7, 1303 | lastupdate=10000000, 1304 | ) 1305 | 1306 | assert_url_query_equals( 1307 | responses.calls[0].request.url, 1308 | { 1309 | "startdateymd": "2019-01-01", 1310 | "enddateymd": "2019-01-02", 1311 | "data_fields": "deepsleepduration,hr_average", 1312 | "offset": "7", 1313 | "lastupdate": "10000000", 1314 | }, 1315 | ) 1316 | 1317 | assert_url_path(responses.calls[0].request.url, "/v2/sleep") 1318 | assert_url_query_equals(responses.calls[0].request.url, {"action": "getsummary"}) 1319 | 1320 | 1321 | @responses.activate 1322 | def test_heart_get_params(withings_api: WithingsApi) -> None: 1323 | """Test function.""" 1324 | responses_add_heart_get(HeartWearPosition.LEFT_ARM.real) 1325 | withings_api.heart_get(signalid=1234567) 1326 | 1327 | assert_url_query_equals( 1328 | responses.calls[0].request.url, {"signalid": "1234567"}, 1329 | ) 1330 | 1331 | assert_url_path(responses.calls[0].request.url, "/v2/heart") 1332 | assert_url_query_equals(responses.calls[0].request.url, {"action": "get"}) 1333 | 1334 | 1335 | @responses.activate 1336 | def test_heart_list_params(withings_api: WithingsApi) -> None: 1337 | """Test function.""" 1338 | responses_add_heart_list() 1339 | withings_api.heart_list(startdate="2020-01-01", enddate="2020-07-23", offset=1) 1340 | 1341 | assert_url_query_equals( 1342 | responses.calls[0].request.url, 1343 | {"startdate": "1577836800", "enddate": "1595462400", "offset": "1"}, 1344 | ) 1345 | 1346 | assert_url_path(responses.calls[0].request.url, "/v2/heart") 1347 | assert_url_query_equals(responses.calls[0].request.url, {"action": "list"}) 1348 | 1349 | 1350 | def assert_url_query_equals(url: str, expected: dict) -> None: 1351 | """Assert a url query contains specific params.""" 1352 | params: Final = dict(parse.parse_qsl(parse.urlsplit(url).query)) 1353 | 1354 | for key in expected: 1355 | assert key in params 1356 | assert params[key] == expected[key] 1357 | 1358 | 1359 | def assert_url_path(url: str, path: str) -> None: 1360 | """Assert the path of a url.""" 1361 | assert parse.urlsplit(url).path == path 1362 | -------------------------------------------------------------------------------- /withings_api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python library for the Withings Health API. 3 | 4 | Withings Health API 5 | 6 | """ 7 | from abc import abstractmethod 8 | import datetime 9 | import json 10 | from types import LambdaType 11 | from typing import Any, Callable, Dict, Iterable, Optional, Union, cast 12 | 13 | import arrow 14 | from oauthlib.common import to_unicode 15 | from oauthlib.oauth2 import WebApplicationClient 16 | from requests import Response 17 | from requests_oauthlib import OAuth2Session 18 | from typing_extensions import Final 19 | 20 | from .common import ( 21 | AuthScope, 22 | Credentials2, 23 | CredentialsType, 24 | GetActivityField, 25 | GetSleepField, 26 | GetSleepSummaryField, 27 | HeartGetResponse, 28 | HeartListResponse, 29 | MeasureGetActivityResponse, 30 | MeasureGetMeasGroupCategory, 31 | MeasureGetMeasResponse, 32 | MeasureType, 33 | NotifyAppli, 34 | NotifyGetResponse, 35 | NotifyListResponse, 36 | SleepGetResponse, 37 | SleepGetSummaryResponse, 38 | UserGetDeviceResponse, 39 | maybe_upgrade_credentials, 40 | response_body_or_raise, 41 | ) 42 | 43 | DateType = Union[arrow.Arrow, datetime.date, datetime.datetime, int, str] 44 | ParamsType = Dict[str, Union[str, int, bool]] 45 | 46 | 47 | def update_params( 48 | params: ParamsType, name: str, current_value: Any, new_value: Any = None 49 | ) -> None: 50 | """Add a conditional param to a params dict.""" 51 | if current_value is None: 52 | return 53 | 54 | if isinstance(new_value, LambdaType): 55 | params[name] = new_value(current_value) 56 | else: 57 | params[name] = new_value or current_value 58 | 59 | 60 | def adjust_withings_token(response: Response) -> Response: 61 | """Restructures token from withings response:: 62 | 63 | { 64 | "status": [{integer} Withings API response status], 65 | "body": { 66 | "access_token": [{string} Your new access_token], 67 | "expires_in": [{integer} Access token expiry delay in seconds], 68 | "token_type": [{string] HTTP Authorization Header format: Bearer], 69 | "scope": [{string} Scopes the user accepted], 70 | "refresh_token": [{string} Your new refresh_token], 71 | "userid": [{string} The Withings ID of the user] 72 | } 73 | } 74 | """ 75 | try: 76 | token = json.loads(response.text) 77 | except Exception: # pylint: disable=broad-except 78 | # If there was exception, just return unmodified response 79 | return response 80 | status = token.pop("status", 0) 81 | if status: 82 | # Set the error to the status 83 | token["error"] = 0 84 | body = token.pop("body", None) 85 | if body: 86 | # Put body content at root level 87 | token.update(body) 88 | # pylint: disable=protected-access 89 | response._content = to_unicode(json.dumps(token)).encode("UTF-8") 90 | 91 | return response 92 | 93 | 94 | class AbstractWithingsApi: 95 | """Abstract class for customizing which requests module you want.""" 96 | 97 | URL: Final = "https://wbsapi.withings.net" 98 | PATH_V2_USER: Final = "v2/user" 99 | PATH_V2_MEASURE: Final = "v2/measure" 100 | PATH_MEASURE: Final = "measure" 101 | PATH_V2_SLEEP: Final = "v2/sleep" 102 | PATH_NOTIFY: Final = "notify" 103 | PATH_V2_HEART: Final = "v2/heart" 104 | 105 | @abstractmethod 106 | def _request( 107 | self, path: str, params: Dict[str, Any], method: str = "GET" 108 | ) -> Dict[str, Any]: 109 | """Fetch data from the Withings API.""" 110 | 111 | def request( 112 | self, path: str, params: Dict[str, Any], method: str = "GET" 113 | ) -> Dict[str, Any]: 114 | """Request a specific service.""" 115 | return response_body_or_raise( 116 | self._request(method=method, path=path, params=params) 117 | ) 118 | 119 | def user_get_device(self) -> UserGetDeviceResponse: 120 | """ 121 | Get user device. 122 | 123 | Some data related to user profile are available through those services. 124 | """ 125 | return UserGetDeviceResponse( 126 | **self.request(path=self.PATH_V2_USER, params={"action": "getdevice"}) 127 | ) 128 | 129 | def measure_get_activity( 130 | self, 131 | data_fields: Iterable[GetActivityField] = GetActivityField, 132 | startdateymd: Optional[DateType] = arrow.utcnow(), 133 | enddateymd: Optional[DateType] = arrow.utcnow(), 134 | offset: Optional[int] = None, 135 | lastupdate: Optional[DateType] = arrow.utcnow(), 136 | ) -> MeasureGetActivityResponse: 137 | """Get user created activities.""" 138 | params: Final[ParamsType] = {} 139 | 140 | update_params( 141 | params, 142 | "startdateymd", 143 | startdateymd, 144 | lambda val: arrow.get(val).format("YYYY-MM-DD"), 145 | ) 146 | update_params( 147 | params, 148 | "enddateymd", 149 | enddateymd, 150 | lambda val: arrow.get(val).format("YYYY-MM-DD"), 151 | ) 152 | update_params(params, "offset", offset) 153 | update_params( 154 | params, 155 | "data_fields", 156 | data_fields, 157 | lambda fields: ",".join([field.value for field in fields]), 158 | ) 159 | update_params( 160 | params, "lastupdate", lastupdate, lambda val: arrow.get(val).int_timestamp 161 | ) 162 | update_params(params, "action", "getactivity") 163 | 164 | return MeasureGetActivityResponse( 165 | **self.request(path=self.PATH_V2_MEASURE, params=params) 166 | ) 167 | 168 | def measure_get_meas( 169 | self, 170 | meastype: Optional[MeasureType] = None, 171 | category: Optional[MeasureGetMeasGroupCategory] = None, 172 | startdate: Optional[DateType] = arrow.utcnow(), 173 | enddate: Optional[DateType] = arrow.utcnow(), 174 | offset: Optional[int] = None, 175 | lastupdate: Optional[DateType] = arrow.utcnow(), 176 | ) -> MeasureGetMeasResponse: 177 | """Get measures.""" 178 | params: Final[ParamsType] = {} 179 | 180 | update_params(params, "meastype", meastype, lambda val: val.value) 181 | update_params(params, "category", category, lambda val: val.value) 182 | update_params( 183 | params, "startdate", startdate, lambda val: arrow.get(val).int_timestamp 184 | ) 185 | update_params( 186 | params, "enddate", enddate, lambda val: arrow.get(val).int_timestamp 187 | ) 188 | update_params(params, "offset", offset) 189 | update_params( 190 | params, "lastupdate", lastupdate, lambda val: arrow.get(val).int_timestamp 191 | ) 192 | update_params(params, "action", "getmeas") 193 | 194 | return MeasureGetMeasResponse( 195 | **self.request(path=self.PATH_MEASURE, params=params) 196 | ) 197 | 198 | def sleep_get( 199 | self, 200 | data_fields: Iterable[GetSleepField], 201 | startdate: Optional[DateType] = arrow.utcnow(), 202 | enddate: Optional[DateType] = arrow.utcnow(), 203 | ) -> SleepGetResponse: 204 | """Get sleep data.""" 205 | params: Final[ParamsType] = {} 206 | 207 | update_params( 208 | params, "startdate", startdate, lambda val: arrow.get(val).int_timestamp 209 | ) 210 | update_params( 211 | params, "enddate", enddate, lambda val: arrow.get(val).int_timestamp 212 | ) 213 | update_params( 214 | params, 215 | "data_fields", 216 | data_fields, 217 | lambda fields: ",".join([field.value for field in fields]), 218 | ) 219 | update_params(params, "action", "get") 220 | 221 | return SleepGetResponse(**self.request(path=self.PATH_V2_SLEEP, params=params)) 222 | 223 | def sleep_get_summary( 224 | self, 225 | data_fields: Iterable[GetSleepSummaryField], 226 | startdateymd: Optional[DateType] = arrow.utcnow(), 227 | enddateymd: Optional[DateType] = arrow.utcnow(), 228 | offset: Optional[int] = None, 229 | lastupdate: Optional[DateType] = arrow.utcnow(), 230 | ) -> SleepGetSummaryResponse: 231 | """Get sleep summary.""" 232 | params: Final[ParamsType] = {} 233 | 234 | update_params( 235 | params, 236 | "startdateymd", 237 | startdateymd, 238 | lambda val: arrow.get(val).format("YYYY-MM-DD"), 239 | ) 240 | update_params( 241 | params, 242 | "enddateymd", 243 | enddateymd, 244 | lambda val: arrow.get(val).format("YYYY-MM-DD"), 245 | ) 246 | update_params( 247 | params, 248 | "data_fields", 249 | data_fields, 250 | lambda fields: ",".join([field.value for field in fields]), 251 | ) 252 | update_params(params, "offset", offset) 253 | update_params( 254 | params, "lastupdate", lastupdate, lambda val: arrow.get(val).int_timestamp 255 | ) 256 | update_params(params, "action", "getsummary") 257 | 258 | return SleepGetSummaryResponse( 259 | **self.request(path=self.PATH_V2_SLEEP, params=params) 260 | ) 261 | 262 | def heart_get(self, signalid: int) -> HeartGetResponse: 263 | """Get ECG recording.""" 264 | params: Final[ParamsType] = {} 265 | 266 | update_params(params, "signalid", signalid) 267 | update_params(params, "action", "get") 268 | 269 | return HeartGetResponse(**self.request(path=self.PATH_V2_HEART, params=params)) 270 | 271 | def heart_list( 272 | self, 273 | startdate: Optional[DateType] = arrow.utcnow(), 274 | enddate: Optional[DateType] = arrow.utcnow(), 275 | offset: Optional[int] = None, 276 | ) -> HeartListResponse: 277 | """Get heart list.""" 278 | params: Final[ParamsType] = {} 279 | 280 | update_params( 281 | params, "startdate", startdate, lambda val: arrow.get(val).int_timestamp, 282 | ) 283 | update_params( 284 | params, "enddate", enddate, lambda val: arrow.get(val).int_timestamp, 285 | ) 286 | update_params(params, "offset", offset) 287 | update_params(params, "action", "list") 288 | 289 | return HeartListResponse(**self.request(path=self.PATH_V2_HEART, params=params)) 290 | 291 | def notify_get( 292 | self, callbackurl: str, appli: Optional[NotifyAppli] = None 293 | ) -> NotifyGetResponse: 294 | """ 295 | Get subscription. 296 | 297 | Return the last notification service that a user was subscribed to, 298 | and its expiry date. 299 | """ 300 | params: Final[ParamsType] = {} 301 | 302 | update_params(params, "callbackurl", callbackurl) 303 | update_params(params, "appli", appli, lambda appli: appli.value) 304 | update_params(params, "action", "get") 305 | 306 | return NotifyGetResponse(**self.request(path=self.PATH_NOTIFY, params=params)) 307 | 308 | def notify_list(self, appli: Optional[NotifyAppli] = None) -> NotifyListResponse: 309 | """List notification configuration for this user.""" 310 | params: Final[ParamsType] = {} 311 | 312 | update_params(params, "appli", appli, lambda appli: appli.value) 313 | update_params(params, "action", "list") 314 | 315 | return NotifyListResponse(**self.request(path=self.PATH_NOTIFY, params=params)) 316 | 317 | def notify_revoke( 318 | self, callbackurl: Optional[str] = None, appli: Optional[NotifyAppli] = None 319 | ) -> None: 320 | """ 321 | Revoke a subscription. 322 | 323 | This service disables the notification between the API and the 324 | specified applications for the user. 325 | """ 326 | params: Final[ParamsType] = {} 327 | 328 | update_params(params, "callbackurl", callbackurl) 329 | update_params(params, "appli", appli, lambda appli: appli.value) 330 | update_params(params, "action", "revoke") 331 | 332 | self.request(path=self.PATH_NOTIFY, params=params) 333 | 334 | def notify_subscribe( 335 | self, 336 | callbackurl: str, 337 | appli: Optional[NotifyAppli] = None, 338 | comment: Optional[str] = None, 339 | ) -> None: 340 | """Subscribe to receive notifications when new data is available.""" 341 | params: Final[ParamsType] = {} 342 | 343 | update_params(params, "callbackurl", callbackurl) 344 | update_params(params, "appli", appli, lambda appli: appli.value) 345 | update_params(params, "comment", comment) 346 | update_params(params, "action", "subscribe") 347 | 348 | self.request(path=self.PATH_NOTIFY, params=params) 349 | 350 | def notify_update( 351 | self, 352 | callbackurl: str, 353 | appli: NotifyAppli, 354 | new_callbackurl: str, 355 | new_appli: Optional[NotifyAppli] = None, 356 | comment: Optional[str] = None, 357 | ) -> None: 358 | """Update the callbackurl and or appli of a created notification.""" 359 | params: Final[ParamsType] = {} 360 | 361 | update_params(params, "callbackurl", callbackurl) 362 | update_params(params, "appli", appli, lambda appli: appli.value) 363 | update_params(params, "new_callbackurl", new_callbackurl) 364 | update_params(params, "new_appli", new_appli, lambda new_appli: new_appli.value) 365 | update_params(params, "comment", comment) 366 | update_params(params, "action", "update") 367 | 368 | self.request(path=self.PATH_NOTIFY, params=params) 369 | 370 | 371 | class WithingsAuth: 372 | """Handles management of oauth2 authorization calls.""" 373 | 374 | URL: Final = "https://account.withings.com" 375 | PATH_AUTHORIZE: Final = "oauth2_user/authorize2" 376 | PATH_V2_OAUTH2: Final = "v2/oauth2" 377 | 378 | def __init__( 379 | self, 380 | client_id: str, 381 | consumer_secret: str, 382 | callback_uri: str, 383 | scope: Iterable[AuthScope] = tuple(), 384 | mode: Optional[str] = None, 385 | ): 386 | """Initialize new object.""" 387 | self._client_id: Final = client_id 388 | self._consumer_secret: Final = consumer_secret 389 | self._callback_uri: Final = callback_uri 390 | self._scope: Final = scope 391 | self._mode: Final = mode 392 | self._session: Final = OAuth2Session( 393 | self._client_id, 394 | redirect_uri=self._callback_uri, 395 | scope=",".join((scope.value for scope in self._scope)), 396 | ) 397 | self._session.register_compliance_hook( 398 | "access_token_response", adjust_withings_token 399 | ) 400 | self._session.register_compliance_hook( 401 | "refresh_token_response", adjust_withings_token 402 | ) 403 | 404 | def get_authorize_url(self) -> str: 405 | """Generate the authorize url.""" 406 | url: Final = str( 407 | self._session.authorization_url( 408 | "%s/%s" % (WithingsAuth.URL, self.PATH_AUTHORIZE) 409 | )[0] 410 | ) 411 | 412 | if self._mode: 413 | return url + "&mode=" + self._mode 414 | 415 | return url 416 | 417 | def get_credentials(self, code: str) -> Credentials2: 418 | """Get the oauth credentials.""" 419 | response: Final = self._session.fetch_token( 420 | "%s/%s" % (AbstractWithingsApi.URL, self.PATH_V2_OAUTH2), 421 | code=code, 422 | client_secret=self._consumer_secret, 423 | include_client_id=True, 424 | action="requesttoken", 425 | ) 426 | 427 | return Credentials2( 428 | **{ 429 | **response, 430 | **dict( 431 | client_id=self._client_id, consumer_secret=self._consumer_secret 432 | ), 433 | } 434 | ) 435 | 436 | 437 | class WithingsApi(AbstractWithingsApi): 438 | """ 439 | Provides entrypoint for calling the withings api. 440 | 441 | While withings-api takes care of automatically refreshing the OAuth2 442 | token so you can seamlessly continue making API calls, it is important 443 | that you persist the updated tokens somewhere associated with the user, 444 | such as a database table. That way when your application restarts it will 445 | have the updated tokens to start with. Pass a ``refresh_cb`` function to 446 | the API constructor and we will call it with the updated token when it gets 447 | refreshed. 448 | 449 | class WithingsUser: 450 | def refresh_cb(self, creds): 451 | my_savefn(creds) 452 | 453 | user = ... 454 | creds = ... 455 | api = WithingsApi(creds, refresh_cb=user.refresh_cb) 456 | """ 457 | 458 | def __init__( 459 | self, 460 | credentials: CredentialsType, 461 | refresh_cb: Optional[Callable[[Credentials2], None]] = None, 462 | ): 463 | """Initialize new object.""" 464 | self._credentials = maybe_upgrade_credentials(credentials) 465 | self._refresh_cb: Final = refresh_cb or self._blank_refresh_cb 466 | token: Final = { 467 | "access_token": self._credentials.access_token, 468 | "refresh_token": self._credentials.refresh_token, 469 | "token_type": self._credentials.token_type, 470 | "expires_in": self._credentials.expires_in, 471 | } 472 | 473 | self._client: Final = OAuth2Session( 474 | self._credentials.client_id, 475 | token=token, 476 | client=WebApplicationClient( # nosec 477 | self._credentials.client_id, 478 | token=token, 479 | default_token_placement="query", 480 | ), 481 | auto_refresh_url="%s/%s" % (self.URL, WithingsAuth.PATH_V2_OAUTH2), 482 | auto_refresh_kwargs={ 483 | "action": "requesttoken", 484 | "client_id": self._credentials.client_id, 485 | "client_secret": self._credentials.consumer_secret, 486 | }, 487 | token_updater=self._update_token, 488 | ) 489 | self._client.register_compliance_hook( 490 | "access_token_response", adjust_withings_token 491 | ) 492 | self._client.register_compliance_hook( 493 | "refresh_token_response", adjust_withings_token 494 | ) 495 | 496 | def _blank_refresh_cb(self, creds: Credentials2) -> None: 497 | """The default callback which does nothing.""" 498 | 499 | def get_credentials(self) -> Credentials2: 500 | """Get the current oauth credentials.""" 501 | return self._credentials 502 | 503 | def refresh_token(self) -> None: 504 | """Manually refresh the token.""" 505 | token_dict: Final = self._client.refresh_token( 506 | token_url=self._client.auto_refresh_url 507 | ) 508 | self._update_token(token=token_dict) 509 | 510 | def _update_token(self, token: Dict[str, Union[str, int]]) -> None: 511 | """Set the oauth token.""" 512 | self._credentials = Credentials2( 513 | access_token=token["access_token"], 514 | expires_in=token["expires_in"], 515 | token_type=self._credentials.token_type, 516 | refresh_token=token["refresh_token"], 517 | userid=self._credentials.userid, 518 | client_id=self._credentials.client_id, 519 | consumer_secret=self._credentials.consumer_secret, 520 | ) 521 | 522 | self._refresh_cb(self._credentials) 523 | 524 | def _request( 525 | self, path: str, params: Dict[str, Any], method: str = "GET" 526 | ) -> Dict[str, Any]: 527 | return cast( 528 | Dict[str, Any], 529 | self._client.request( 530 | method=method, 531 | url="%s/%s" % (self.URL.strip("/"), path.strip("/")), 532 | params=params, 533 | ).json(), 534 | ) 535 | -------------------------------------------------------------------------------- /withings_api/common.py: -------------------------------------------------------------------------------- 1 | """Common classes and functions.""" 2 | from dataclasses import dataclass 3 | from datetime import tzinfo 4 | from enum import Enum, IntEnum 5 | import logging 6 | from typing import Any, Dict, Optional, Tuple, Type, TypeVar, Union, cast 7 | 8 | import arrow 9 | from arrow import Arrow 10 | from dateutil import tz 11 | from dateutil.tz import tzlocal 12 | from pydantic import BaseModel, Field, validator 13 | from typing_extensions import Final 14 | 15 | from .const import ( 16 | LOG_NAMESPACE, 17 | STATUS_AUTH_FAILED, 18 | STATUS_BAD_STATE, 19 | STATUS_ERROR_OCCURRED, 20 | STATUS_INVALID_PARAMS, 21 | STATUS_SUCCESS, 22 | STATUS_TIMEOUT, 23 | STATUS_TOO_MANY_REQUESTS, 24 | STATUS_UNAUTHORIZED, 25 | ) 26 | 27 | _LOGGER = logging.getLogger(LOG_NAMESPACE) 28 | _GenericType = TypeVar("_GenericType") 29 | 30 | 31 | def to_enum( 32 | enum_class: Type[_GenericType], value: Any, default_value: _GenericType 33 | ) -> _GenericType: 34 | """Attempt to convert a value to an enum.""" 35 | try: 36 | return enum_class(value) # type: ignore 37 | except ValueError: 38 | _LOGGER.warning( 39 | "Unsupported %s value %s. Replacing with UNKNOWN value %s. Please report this warning to the developer to ensure proper support.", 40 | str(enum_class), 41 | value, 42 | str(default_value), 43 | ) 44 | return default_value 45 | 46 | 47 | class ConfiguredBaseModel(BaseModel): 48 | """An already configured pydantic model.""" 49 | 50 | class Config: 51 | """Config for pydantic model.""" 52 | 53 | ignore_extra: Final = True 54 | allow_extra: Final = False 55 | allow_mutation: Final = False 56 | 57 | 58 | class TimeZone(tzlocal): 59 | """Subclass of tzinfo for parsing timezones.""" 60 | 61 | @classmethod 62 | def __get_validators__(cls) -> Any: 63 | # one or more validators may be yielded which will be called in the 64 | # order to validate the input, each validator will receive as an input 65 | # the value returned from the previous validator 66 | yield cls.validate 67 | 68 | @classmethod 69 | def validate(cls, value: Any) -> tzinfo: 70 | """Convert input to the desired object.""" 71 | if isinstance(value, tzinfo): 72 | return value 73 | if isinstance(value, str): 74 | timezone: Final = tz.gettz(value) 75 | if timezone: 76 | return timezone 77 | raise ValueError(f"Invalid timezone provided {value}") 78 | 79 | raise TypeError("string or tzinfo required") 80 | 81 | 82 | class ArrowType(Arrow): 83 | """Subclass of Arrow for parsing dates.""" 84 | 85 | @classmethod 86 | def __get_validators__(cls) -> Any: 87 | # one or more validators may be yielded which will be called in the 88 | # order to validate the input, each validator will receive as an input 89 | # the value returned from the previous validator 90 | yield cls.validate 91 | 92 | @classmethod 93 | def validate(cls, value: Any) -> Arrow: 94 | """Convert input to the desired object.""" 95 | if isinstance(value, str): 96 | if value.isdigit(): 97 | return arrow.get(int(value)) 98 | return arrow.get(value) 99 | if isinstance(value, int): 100 | return arrow.get(value) 101 | if isinstance(value, (Arrow, ArrowType)): 102 | return value 103 | 104 | raise TypeError("string or int required") 105 | 106 | 107 | class SleepModel(IntEnum): 108 | """Sleep model.""" 109 | 110 | UNKNOWN = -999999 111 | TRACKER = 16 112 | SLEEP_MONITOR = 32 113 | 114 | 115 | class SleepState(IntEnum): 116 | """Sleep states.""" 117 | 118 | UNKNOWN = -999999 119 | AWAKE = 0 120 | LIGHT = 1 121 | DEEP = 2 122 | REM = 3 123 | 124 | 125 | class MeasureGetMeasGroupAttrib(IntEnum): 126 | """Measure group attributions.""" 127 | 128 | UNKNOWN = -1 129 | DEVICE_ENTRY_FOR_USER = 0 130 | DEVICE_ENTRY_FOR_USER_AMBIGUOUS = 1 131 | MANUAL_USER_ENTRY = 2 132 | MANUAL_USER_DURING_ACCOUNT_CREATION = 4 133 | MEASURE_AUTO = 5 134 | MEASURE_USER_CONFIRMED = 7 135 | SAME_AS_DEVICE_ENTRY_FOR_USER = 8 136 | 137 | 138 | class MeasureGetMeasGroupCategory(IntEnum): 139 | """Measure categories.""" 140 | 141 | UNKNOWN = -999999 142 | REAL = 1 143 | USER_OBJECTIVES = 2 144 | 145 | 146 | class MeasureType(IntEnum): 147 | """Measure types.""" 148 | 149 | UNKNOWN = -999999 150 | WEIGHT = 1 151 | HEIGHT = 4 152 | FAT_FREE_MASS = 5 153 | FAT_RATIO = 6 154 | FAT_MASS_WEIGHT = 8 155 | DIASTOLIC_BLOOD_PRESSURE = 9 156 | SYSTOLIC_BLOOD_PRESSURE = 10 157 | HEART_RATE = 11 158 | TEMPERATURE = 12 159 | SP02 = 54 160 | BODY_TEMPERATURE = 71 161 | SKIN_TEMPERATURE = 73 162 | MUSCLE_MASS = 76 163 | HYDRATION = 77 164 | BONE_MASS = 88 165 | PULSE_WAVE_VELOCITY = 91 166 | VO2 = 123 167 | QRS_INTERVAL = 135 168 | PR_INTERVAL = 136 169 | QT_INTERVAL = 138 170 | ATRIAL_FIBRILLATION = 139 171 | 172 | 173 | class NotifyAppli(IntEnum): 174 | """Data to notify_subscribe to.""" 175 | 176 | UNKNOWN = -999999 177 | WEIGHT = 1 178 | CIRCULATORY = 4 179 | ACTIVITY = 16 180 | SLEEP = 44 181 | USER = 46 182 | BED_IN = 50 183 | BED_OUT = 51 184 | 185 | 186 | class GetActivityField(Enum): 187 | """Fields for the getactivity api call.""" 188 | 189 | STEPS = "steps" 190 | DISTANCE = "distance" 191 | ELEVATION = "elevation" 192 | SOFT = "soft" 193 | MODERATE = "moderate" 194 | INTENSE = "intense" 195 | ACTIVE = "active" 196 | CALORIES = "calories" 197 | TOTAL_CALORIES = "totalcalories" 198 | HR_AVERAGE = "hr_average" 199 | HR_MIN = "hr_min" 200 | HR_MAX = "hr_max" 201 | HR_ZONE_0 = "hr_zone_0" 202 | HR_ZONE_1 = "hr_zone_1" 203 | HR_ZONE_2 = "hr_zone_2" 204 | HR_ZONE_3 = "hr_zone_3" 205 | 206 | 207 | class GetSleepField(Enum): 208 | """Fields for getsleep api call.""" 209 | 210 | HR = "hr" 211 | RR = "rr" 212 | SNORING = "snoring" 213 | 214 | 215 | class GetSleepSummaryField(Enum): 216 | """Fields for get sleep summary api call.""" 217 | 218 | BREATHING_DISTURBANCES_INTENSITY = "breathing_disturbances_intensity" 219 | DEEP_SLEEP_DURATION = "deepsleepduration" 220 | DURATION_TO_SLEEP = "durationtosleep" 221 | DURATION_TO_WAKEUP = "durationtowakeup" 222 | HR_AVERAGE = "hr_average" 223 | HR_MAX = "hr_max" 224 | HR_MIN = "hr_min" 225 | LIGHT_SLEEP_DURATION = "lightsleepduration" 226 | REM_SLEEP_DURATION = "remsleepduration" 227 | RR_AVERAGE = "rr_average" 228 | RR_MAX = "rr_max" 229 | RR_MIN = "rr_min" 230 | SLEEP_SCORE = "sleep_score" 231 | SNORING = "snoring" 232 | SNORING_EPISODE_COUNT = "snoringepisodecount" 233 | WAKEUP_COUNT = "wakeupcount" 234 | WAKEUP_DURATION = "wakeupduration" 235 | 236 | 237 | class AuthScope(Enum): 238 | """Authorization scopes.""" 239 | 240 | USER_INFO = "user.info" 241 | USER_METRICS = "user.metrics" 242 | USER_ACTIVITY = "user.activity" 243 | USER_SLEEP_EVENTS = "user.sleepevents" 244 | 245 | 246 | class UserGetDeviceDevice(ConfiguredBaseModel): 247 | """UserGetDeviceDevice.""" 248 | 249 | type: str 250 | model: str 251 | battery: str 252 | deviceid: str 253 | timezone: TimeZone 254 | 255 | 256 | class UserGetDeviceResponse(ConfiguredBaseModel): 257 | """UserGetDeviceResponse.""" 258 | 259 | devices: Tuple[UserGetDeviceDevice, ...] 260 | 261 | 262 | class SleepGetTimestampValue(ConfiguredBaseModel): 263 | """SleepGetTimestampValue.""" 264 | 265 | timestamp: ArrowType 266 | value: int 267 | 268 | 269 | class SleepGetSerie(ConfiguredBaseModel): 270 | """SleepGetSerie.""" 271 | 272 | enddate: ArrowType 273 | startdate: ArrowType 274 | state: SleepState 275 | hr: Tuple[SleepGetTimestampValue, ...] = () # pylint: disable=invalid-name 276 | rr: Tuple[SleepGetTimestampValue, ...] = () # pylint: disable=invalid-name 277 | snoring: Tuple[SleepGetTimestampValue, ...] = () 278 | 279 | @validator("hr", pre=True) 280 | @classmethod 281 | def _hr_to_tuple(cls, value: Dict[str, int]) -> Tuple: 282 | return SleepGetSerie._timestamp_value_to_object(value) 283 | 284 | @validator("rr", pre=True) 285 | @classmethod 286 | def _rr_to_tuple(cls, value: Dict[str, int]) -> Tuple: 287 | return SleepGetSerie._timestamp_value_to_object(value) 288 | 289 | @validator("snoring", pre=True) 290 | @classmethod 291 | def _snoring_to_tuple(cls, value: Dict[str, int]) -> Tuple: 292 | return SleepGetSerie._timestamp_value_to_object(value) 293 | 294 | @classmethod 295 | def _timestamp_value_to_object( 296 | cls, value: Any 297 | ) -> Tuple[SleepGetTimestampValue, ...]: 298 | if not value: 299 | return () 300 | if isinstance(value, dict): 301 | return tuple( 302 | [ 303 | SleepGetTimestampValue(timestamp=item_key, value=item_value) 304 | for item_key, item_value in value.items() 305 | ] 306 | ) 307 | 308 | return cast(Tuple[SleepGetTimestampValue, ...], value) 309 | 310 | @validator("state", pre=True) 311 | @classmethod 312 | def _state_to_enum(cls, value: Any) -> SleepState: 313 | return to_enum(SleepState, value, SleepState.UNKNOWN) 314 | 315 | 316 | class SleepGetResponse(ConfiguredBaseModel): 317 | """SleepGetResponse.""" 318 | 319 | model: SleepModel 320 | series: Tuple[SleepGetSerie, ...] 321 | 322 | @validator("model", pre=True) 323 | @classmethod 324 | def _model_to_enum(cls, value: Any) -> SleepModel: 325 | return to_enum(SleepModel, value, SleepModel.UNKNOWN) 326 | 327 | 328 | class GetSleepSummaryData( 329 | ConfiguredBaseModel 330 | ): # pylint: disable=too-many-instance-attributes 331 | """GetSleepSummaryData.""" 332 | 333 | breathing_disturbances_intensity: Optional[int] 334 | deepsleepduration: Optional[int] 335 | durationtosleep: Optional[int] 336 | durationtowakeup: Optional[int] 337 | hr_average: Optional[int] 338 | hr_max: Optional[int] 339 | hr_min: Optional[int] 340 | lightsleepduration: Optional[int] 341 | remsleepduration: Optional[int] 342 | rr_average: Optional[int] 343 | rr_max: Optional[int] 344 | rr_min: Optional[int] 345 | sleep_score: Optional[int] 346 | snoring: Optional[int] 347 | snoringepisodecount: Optional[int] 348 | wakeupcount: Optional[int] 349 | wakeupduration: Optional[int] 350 | 351 | 352 | class GetSleepSummarySerie(ConfiguredBaseModel): 353 | """GetSleepSummarySerie.""" 354 | 355 | timezone: TimeZone 356 | model: SleepModel 357 | startdate: ArrowType 358 | enddate: ArrowType 359 | date: ArrowType 360 | modified: ArrowType 361 | data: GetSleepSummaryData 362 | id: Optional[int] = None 363 | 364 | @validator("startdate") 365 | @classmethod 366 | def _set_timezone_on_startdate( 367 | cls, value: ArrowType, values: Dict[str, Any] 368 | ) -> Arrow: 369 | return cast(Arrow, value.to(values["timezone"])) 370 | 371 | @validator("enddate") 372 | @classmethod 373 | def _set_timezone_on_enddate( 374 | cls, value: ArrowType, values: Dict[str, Any] 375 | ) -> Arrow: 376 | return cast(Arrow, value.to(values["timezone"])) 377 | 378 | @validator("date") 379 | @classmethod 380 | def _set_timezone_on_date(cls, value: ArrowType, values: Dict[str, Any]) -> Arrow: 381 | return cast(Arrow, value.to(values["timezone"])) 382 | 383 | @validator("modified") 384 | @classmethod 385 | def _set_timezone_on_modified( 386 | cls, value: ArrowType, values: Dict[str, Any] 387 | ) -> Arrow: 388 | return cast(Arrow, value.to(values["timezone"])) 389 | 390 | @validator("model", pre=True) 391 | @classmethod 392 | def _model_to_enum(cls, value: Any) -> SleepModel: 393 | return to_enum(SleepModel, value, SleepModel.UNKNOWN) 394 | 395 | 396 | class SleepGetSummaryResponse(ConfiguredBaseModel): 397 | """SleepGetSummaryResponse.""" 398 | 399 | more: bool 400 | offset: int 401 | series: Tuple[GetSleepSummarySerie, ...] 402 | 403 | 404 | class MeasureGetMeasMeasure(ConfiguredBaseModel): 405 | """MeasureGetMeasMeasure.""" 406 | 407 | type: MeasureType 408 | unit: int 409 | value: int 410 | 411 | @validator("type", pre=True) 412 | @classmethod 413 | def _type_to_enum(cls, value: Any) -> MeasureType: 414 | return to_enum(MeasureType, value, MeasureType.UNKNOWN) 415 | 416 | 417 | class MeasureGetMeasGroup(ConfiguredBaseModel): 418 | """MeasureGetMeasGroup.""" 419 | 420 | attrib: MeasureGetMeasGroupAttrib 421 | category: MeasureGetMeasGroupCategory 422 | created: ArrowType 423 | date: ArrowType 424 | deviceid: Optional[str] 425 | grpid: int 426 | measures: Tuple[MeasureGetMeasMeasure, ...] 427 | 428 | @validator("attrib", pre=True) 429 | @classmethod 430 | def _attrib_to_enum(cls, value: Any) -> MeasureGetMeasGroupAttrib: 431 | return to_enum( 432 | MeasureGetMeasGroupAttrib, value, MeasureGetMeasGroupAttrib.UNKNOWN 433 | ) 434 | 435 | @validator("category", pre=True) 436 | @classmethod 437 | def _category_to_enum(cls, value: Any) -> MeasureGetMeasGroupCategory: 438 | return to_enum( 439 | MeasureGetMeasGroupCategory, value, MeasureGetMeasGroupCategory.UNKNOWN 440 | ) 441 | 442 | 443 | class MeasureGetMeasResponse(ConfiguredBaseModel): 444 | """MeasureGetMeasResponse.""" 445 | 446 | measuregrps: Tuple[MeasureGetMeasGroup, ...] 447 | more: Optional[bool] 448 | offset: Optional[int] 449 | timezone: TimeZone 450 | updatetime: ArrowType 451 | 452 | @validator("updatetime") 453 | @classmethod 454 | def _set_timezone_on_updatetime( 455 | cls, value: ArrowType, values: Dict[str, Any] 456 | ) -> Arrow: 457 | return cast(Arrow, value.to(values["timezone"])) 458 | 459 | 460 | class MeasureGetActivityActivity( 461 | BaseModel 462 | ): # pylint: disable=too-many-instance-attributes 463 | """MeasureGetActivityActivity.""" 464 | 465 | date: ArrowType 466 | timezone: TimeZone 467 | deviceid: Optional[str] 468 | brand: int 469 | is_tracker: bool 470 | steps: Optional[int] 471 | distance: Optional[float] 472 | elevation: Optional[float] 473 | soft: Optional[int] 474 | moderate: Optional[int] 475 | intense: Optional[int] 476 | active: Optional[int] 477 | calories: Optional[float] 478 | totalcalories: float 479 | hr_average: Optional[int] 480 | hr_min: Optional[int] 481 | hr_max: Optional[int] 482 | hr_zone_0: Optional[int] 483 | hr_zone_1: Optional[int] 484 | hr_zone_2: Optional[int] 485 | hr_zone_3: Optional[int] 486 | 487 | 488 | class MeasureGetActivityResponse(ConfiguredBaseModel): 489 | """MeasureGetActivityResponse.""" 490 | 491 | activities: Tuple[MeasureGetActivityActivity, ...] 492 | more: bool 493 | offset: int 494 | 495 | 496 | class HeartModel(IntEnum): 497 | """Heart model.""" 498 | 499 | UNKNOWN = -999999 500 | BPM_CORE = 44 501 | MOVE_ECG = 91 502 | SCANWATCH = 93 503 | 504 | 505 | class AfibClassification(IntEnum): 506 | """Atrial fibrillation classification""" 507 | 508 | UNKNOWN = -999999 509 | NEGATIVE = 0 510 | POSITIVE = 1 511 | INCONCLUSIVE = 2 512 | 513 | 514 | class HeartWearPosition(IntEnum): 515 | """Wear position of heart model.""" 516 | 517 | UNKNOWN = -999999 518 | RIGHT_WRIST = 0 519 | LEFT_WRIST = 1 520 | RIGHT_ARM = 2 521 | LEFT_ARM = 3 522 | RIGHT_FOOT = 4 523 | LEFT_FOOT = 5 524 | 525 | 526 | class HeartGetResponse(ConfiguredBaseModel): 527 | """HeartGetResponse.""" 528 | 529 | signal: Tuple[int, ...] 530 | sampling_frequency: int 531 | wearposition: HeartWearPosition 532 | 533 | @validator("wearposition", pre=True) 534 | @classmethod 535 | def _wearposition_to_enum(cls, value: Any) -> HeartWearPosition: 536 | return to_enum(HeartWearPosition, value, HeartWearPosition.UNKNOWN) 537 | 538 | 539 | class HeartListECG(ConfiguredBaseModel): 540 | """HeartListECG.""" 541 | 542 | signalid: int 543 | afib: AfibClassification 544 | 545 | @validator("afib", pre=True) 546 | @classmethod 547 | def _afib_to_enum(cls, value: Any) -> AfibClassification: 548 | return to_enum(AfibClassification, value, AfibClassification.UNKNOWN) 549 | 550 | 551 | class HeartBloodPressure(ConfiguredBaseModel): 552 | """HeartBloodPressure.""" 553 | 554 | diastole: int 555 | systole: int 556 | 557 | 558 | class HeartListSerie(ConfiguredBaseModel): 559 | """HeartListSerie""" 560 | 561 | ecg: HeartListECG 562 | 563 | heart_rate: int 564 | timestamp: ArrowType 565 | model: HeartModel 566 | 567 | # blood pressure is optional as not all devices (e.g. Move ECG) collect it 568 | bloodpressure: Optional[HeartBloodPressure] = None 569 | 570 | deviceid: Optional[str] = None 571 | 572 | @validator("model", pre=True) 573 | @classmethod 574 | def _model_to_enum(cls, value: Any) -> HeartModel: 575 | return to_enum(HeartModel, value, HeartModel.UNKNOWN) 576 | 577 | 578 | class HeartListResponse(ConfiguredBaseModel): 579 | """HeartListResponse.""" 580 | 581 | more: bool 582 | offset: int 583 | series: Tuple[HeartListSerie, ...] 584 | 585 | 586 | @dataclass(frozen=True) 587 | class Credentials: 588 | """Credentials.""" 589 | 590 | access_token: str 591 | token_expiry: int 592 | token_type: str 593 | refresh_token: str 594 | userid: int 595 | client_id: str 596 | consumer_secret: str 597 | 598 | 599 | class Credentials2(ConfiguredBaseModel): 600 | """Credentials.""" 601 | 602 | access_token: str 603 | token_type: str 604 | refresh_token: str 605 | userid: int 606 | client_id: str 607 | consumer_secret: str 608 | expires_in: int 609 | created: ArrowType = Field(default_factory=arrow.utcnow) 610 | 611 | @property 612 | def token_expiry(self) -> int: 613 | """Get the token expiry.""" 614 | return cast(int, self.created.shift(seconds=self.expires_in).int_timestamp) 615 | 616 | 617 | CredentialsType = Union[Credentials, Credentials2] 618 | 619 | 620 | def maybe_upgrade_credentials(value: CredentialsType) -> Credentials2: 621 | """Upgrade older versions of credentials to the newer signature.""" 622 | if isinstance(value, Credentials2): 623 | return value 624 | 625 | creds = cast(Credentials, value) 626 | return Credentials2( 627 | access_token=creds.access_token, 628 | token_type=creds.token_type, 629 | refresh_token=creds.refresh_token, 630 | userid=creds.userid, 631 | client_id=creds.client_id, 632 | consumer_secret=creds.consumer_secret, 633 | expires_in=creds.token_expiry - arrow.utcnow().int_timestamp, 634 | ) 635 | 636 | 637 | class NotifyListProfile(ConfiguredBaseModel): 638 | """NotifyListProfile.""" 639 | 640 | appli: NotifyAppli 641 | callbackurl: str 642 | expires: Optional[ArrowType] 643 | comment: Optional[str] 644 | 645 | @validator("appli", pre=True) 646 | @classmethod 647 | def _appli_to_enum(cls, value: Any) -> NotifyAppli: 648 | return to_enum(NotifyAppli, value, NotifyAppli.UNKNOWN) 649 | 650 | 651 | class NotifyListResponse(ConfiguredBaseModel): 652 | """NotifyListResponse.""" 653 | 654 | profiles: Tuple[NotifyListProfile, ...] 655 | 656 | 657 | class NotifyGetResponse(ConfiguredBaseModel): 658 | """NotifyGetResponse.""" 659 | 660 | appli: NotifyAppli 661 | callbackurl: str 662 | comment: Optional[str] 663 | 664 | @validator("appli", pre=True) 665 | @classmethod 666 | def _appli_to_enum(cls, value: Any) -> NotifyAppli: 667 | return to_enum(NotifyAppli, value, NotifyAppli.UNKNOWN) 668 | 669 | 670 | class UnexpectedTypeException(Exception): 671 | """Thrown when encountering an unexpected type.""" 672 | 673 | def __init__(self, value: Any, expected: Type[_GenericType]): 674 | """Initialize.""" 675 | super().__init__( 676 | 'Expected of "%s" to be "%s" but was "%s."' % (value, expected, type(value)) 677 | ) 678 | 679 | 680 | AMBIGUOUS_GROUP_ATTRIBS: Final = ( 681 | MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, 682 | MeasureGetMeasGroupAttrib.MANUAL_USER_DURING_ACCOUNT_CREATION, 683 | ) 684 | 685 | 686 | class MeasureGroupAttribs: 687 | """Groups of MeasureGetMeasGroupAttrib.""" 688 | 689 | ANY: Final = tuple(enum_val for enum_val in MeasureGetMeasGroupAttrib) 690 | AMBIGUOUS: Final = AMBIGUOUS_GROUP_ATTRIBS 691 | UNAMBIGUOUS: Final = tuple( 692 | enum_val 693 | for enum_val in MeasureGetMeasGroupAttrib 694 | if enum_val not in AMBIGUOUS_GROUP_ATTRIBS 695 | ) 696 | 697 | 698 | class MeasureTypes: 699 | """Groups of MeasureType.""" 700 | 701 | ANY: Final = tuple(enum_val for enum_val in MeasureType) 702 | 703 | 704 | def query_measure_groups( 705 | from_source: Union[ 706 | MeasureGetMeasGroup, MeasureGetMeasResponse, Tuple[MeasureGetMeasGroup, ...] 707 | ], 708 | with_measure_type: Union[MeasureType, Tuple[MeasureType, ...]] = MeasureTypes.ANY, 709 | with_group_attrib: Union[ 710 | MeasureGetMeasGroupAttrib, Tuple[MeasureGetMeasGroupAttrib, ...] 711 | ] = MeasureGroupAttribs.ANY, 712 | ) -> Tuple[MeasureGetMeasGroup, ...]: 713 | """Return a groups and measurements based on filters.""" 714 | if isinstance(from_source, MeasureGetMeasResponse): 715 | iter_groups = cast(MeasureGetMeasResponse, from_source).measuregrps 716 | elif isinstance(from_source, MeasureGetMeasGroup): 717 | iter_groups = (cast(MeasureGetMeasGroup, from_source),) 718 | else: 719 | iter_groups = cast(Tuple[MeasureGetMeasGroup], from_source) 720 | 721 | if isinstance(with_measure_type, MeasureType): 722 | iter_measure_type = (cast(MeasureType, with_measure_type),) 723 | else: 724 | iter_measure_type = cast(Tuple[MeasureType], with_measure_type) 725 | 726 | if isinstance(with_group_attrib, MeasureGetMeasGroupAttrib): 727 | iter_group_attrib = (cast(MeasureGetMeasGroupAttrib, with_group_attrib),) 728 | else: 729 | iter_group_attrib = cast(Tuple[MeasureGetMeasGroupAttrib], with_group_attrib) 730 | 731 | return tuple( 732 | MeasureGetMeasGroup( 733 | attrib=group.attrib, 734 | category=group.category, 735 | created=group.created, 736 | date=group.date, 737 | deviceid=group.deviceid, 738 | grpid=group.grpid, 739 | measures=tuple( 740 | measure 741 | for measure in group.measures 742 | if measure.type in iter_measure_type 743 | ), 744 | ) 745 | for group in iter_groups 746 | if group.attrib in iter_group_attrib 747 | ) 748 | 749 | 750 | def get_measure_value( 751 | from_source: Union[ 752 | MeasureGetMeasGroup, MeasureGetMeasResponse, Tuple[MeasureGetMeasGroup, ...] 753 | ], 754 | with_measure_type: Union[MeasureType, Tuple[MeasureType, ...]], 755 | with_group_attrib: Union[ 756 | MeasureGetMeasGroupAttrib, Tuple[MeasureGetMeasGroupAttrib, ...] 757 | ] = MeasureGroupAttribs.ANY, 758 | ) -> Optional[float]: 759 | """Get the first value of a measure that meet the query requirements.""" 760 | groups: Final = query_measure_groups( 761 | from_source, with_measure_type, with_group_attrib 762 | ) 763 | 764 | return next( 765 | iter( 766 | tuple( 767 | float(measure.value * pow(10, measure.unit)) 768 | for group in groups 769 | for measure in group.measures 770 | ) 771 | ), 772 | None, 773 | ) 774 | 775 | 776 | class StatusException(Exception): 777 | """Status exception.""" 778 | 779 | def __init__(self, status: Any): 780 | """Create instance.""" 781 | super().__init__("Error code %s" % str(status)) 782 | 783 | 784 | class AuthFailedException(StatusException): 785 | """Withings status error code exception.""" 786 | 787 | 788 | class InvalidParamsException(StatusException): 789 | """Withings status error code exception.""" 790 | 791 | 792 | class UnauthorizedException(StatusException): 793 | """Withings status error code exception.""" 794 | 795 | 796 | class ErrorOccurredException(StatusException): 797 | """Withings status error code exception.""" 798 | 799 | 800 | class TimeoutException(StatusException): 801 | """Withings status error code exception.""" 802 | 803 | 804 | class BadStateException(StatusException): 805 | """Withings status error code exception.""" 806 | 807 | 808 | class TooManyRequestsException(StatusException): 809 | """Withings status error code exception.""" 810 | 811 | 812 | class UnknownStatusException(StatusException): 813 | """Unknown status code but it's still not successful.""" 814 | 815 | 816 | def response_body_or_raise(data: Any) -> Dict[str, Any]: 817 | """Parse withings response or raise exception.""" 818 | if not isinstance(data, dict): 819 | raise UnexpectedTypeException(data, dict) 820 | 821 | parsed_response: Final = cast(dict, data) 822 | status: Final = parsed_response.get("status") 823 | 824 | if status is None: 825 | raise UnknownStatusException(status=status) 826 | if status in STATUS_SUCCESS: 827 | return cast(Dict[str, Any], parsed_response.get("body")) 828 | if status in STATUS_AUTH_FAILED: 829 | raise AuthFailedException(status=status) 830 | if status in STATUS_INVALID_PARAMS: 831 | raise InvalidParamsException(status=status) 832 | if status in STATUS_UNAUTHORIZED: 833 | raise UnauthorizedException(status=status) 834 | if status in STATUS_ERROR_OCCURRED: 835 | raise ErrorOccurredException(status=status) 836 | if status in STATUS_TIMEOUT: 837 | raise TimeoutException(status=status) 838 | if status in STATUS_BAD_STATE: 839 | raise BadStateException(status=status) 840 | if status in STATUS_TOO_MANY_REQUESTS: 841 | raise TooManyRequestsException(status=status) 842 | raise UnknownStatusException(status=status) 843 | -------------------------------------------------------------------------------- /withings_api/const.py: -------------------------------------------------------------------------------- 1 | """Constant values.""" 2 | from typing_extensions import Final 3 | 4 | LOG_NAMESPACE: Final = "python_withings_api" 5 | 6 | STATUS_SUCCESS: Final = (0,) 7 | 8 | STATUS_AUTH_FAILED: Final = (100, 101, 102, 200, 401) 9 | 10 | STATUS_INVALID_PARAMS: Final = ( 11 | 201, 12 | 202, 13 | 203, 14 | 204, 15 | 205, 16 | 206, 17 | 207, 18 | 208, 19 | 209, 20 | 210, 21 | 211, 22 | 212, 23 | 213, 24 | 216, 25 | 217, 26 | 218, 27 | 220, 28 | 221, 29 | 223, 30 | 225, 31 | 227, 32 | 228, 33 | 229, 34 | 230, 35 | 234, 36 | 235, 37 | 236, 38 | 238, 39 | 240, 40 | 241, 41 | 242, 42 | 243, 43 | 244, 44 | 245, 45 | 246, 46 | 247, 47 | 248, 48 | 249, 49 | 250, 50 | 251, 51 | 252, 52 | 254, 53 | 260, 54 | 261, 55 | 262, 56 | 263, 57 | 264, 58 | 265, 59 | 266, 60 | 267, 61 | 271, 62 | 272, 63 | 275, 64 | 276, 65 | 283, 66 | 284, 67 | 285, 68 | 286, 69 | 287, 70 | 288, 71 | 290, 72 | 293, 73 | 294, 74 | 295, 75 | 297, 76 | 300, 77 | 301, 78 | 302, 79 | 303, 80 | 304, 81 | 321, 82 | 323, 83 | 324, 84 | 325, 85 | 326, 86 | 327, 87 | 328, 88 | 329, 89 | 330, 90 | 331, 91 | 332, 92 | 333, 93 | 334, 94 | 335, 95 | 336, 96 | 337, 97 | 338, 98 | 339, 99 | 340, 100 | 341, 101 | 342, 102 | 343, 103 | 344, 104 | 345, 105 | 346, 106 | 347, 107 | 348, 108 | 349, 109 | 350, 110 | 351, 111 | 352, 112 | 353, 113 | 380, 114 | 381, 115 | 382, 116 | 400, 117 | 501, 118 | 502, 119 | 503, 120 | 504, 121 | 505, 122 | 506, 123 | 509, 124 | 510, 125 | 511, 126 | 523, 127 | 532, 128 | 3017, 129 | 3018, 130 | 3019, 131 | ) 132 | 133 | STATUS_UNAUTHORIZED: Final = (214, 277, 2553, 2554, 2555) 134 | 135 | STATUS_ERROR_OCCURRED: Final = ( 136 | 215, 137 | 219, 138 | 222, 139 | 224, 140 | 226, 141 | 231, 142 | 233, 143 | 237, 144 | 253, 145 | 255, 146 | 256, 147 | 257, 148 | 258, 149 | 259, 150 | 268, 151 | 269, 152 | 270, 153 | 273, 154 | 274, 155 | 278, 156 | 279, 157 | 280, 158 | 281, 159 | 282, 160 | 289, 161 | 291, 162 | 292, 163 | 296, 164 | 298, 165 | 305, 166 | 306, 167 | 308, 168 | 309, 169 | 310, 170 | 311, 171 | 312, 172 | 313, 173 | 314, 174 | 315, 175 | 316, 176 | 317, 177 | 318, 178 | 319, 179 | 320, 180 | 322, 181 | 370, 182 | 371, 183 | 372, 184 | 373, 185 | 374, 186 | 375, 187 | 383, 188 | 391, 189 | 402, 190 | 516, 191 | 517, 192 | 518, 193 | 519, 194 | 520, 195 | 521, 196 | 525, 197 | 526, 198 | 527, 199 | 528, 200 | 529, 201 | 530, 202 | 531, 203 | 533, 204 | 602, 205 | 700, 206 | 1051, 207 | 1052, 208 | 1053, 209 | 1054, 210 | 2551, 211 | 2552, 212 | 2556, 213 | 2557, 214 | 2558, 215 | 2559, 216 | 3000, 217 | 3001, 218 | 3002, 219 | 3003, 220 | 3004, 221 | 3005, 222 | 3006, 223 | 3007, 224 | 3008, 225 | 3009, 226 | 3010, 227 | 3011, 228 | 3012, 229 | 3013, 230 | 3014, 231 | 3015, 232 | 3016, 233 | 3020, 234 | 3021, 235 | 3022, 236 | 3023, 237 | 3024, 238 | 5000, 239 | 5001, 240 | 5005, 241 | 5006, 242 | 6000, 243 | 6010, 244 | 6011, 245 | 9000, 246 | 10000, 247 | ) 248 | 249 | STATUS_TIMEOUT: Final = (522,) 250 | STATUS_BAD_STATE: Final = (524,) 251 | STATUS_TOO_MANY_REQUESTS: Final = (601,) 252 | --------------------------------------------------------------------------------