├── .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 [](https://github.com/vangorra/python_withings_api/actions?workflow=Build) [](https://coveralls.io/github/vangorra/python_withings_api?branch=master) [](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 |
--------------------------------------------------------------------------------