pax_global_header00006660000000000000000000000064144207645710014524gustar00rootroot0000000000000052 comment=5d32509ed6bc6e73ab52cde72573d8d84d7707a0 stravalib-1.3.0/000077500000000000000000000000001442076457100135145ustar00rootroot00000000000000stravalib-1.3.0/.coveragerc000066400000000000000000000001051442076457100156310ustar00rootroot00000000000000[run] branch = True omit = */tests/* */_version_generated.py stravalib-1.3.0/.flake8000066400000000000000000000006661442076457100146770ustar00rootroot00000000000000[flake8] # Ignore style and complexity # E: style errors # W: style warnings # C: complexity # D: docstring warnings (unused pydocstyle extension) # F841: local variable assigned but never used # So right now with the settings below it won't highlight # every long line etc. this would allow us to fix things and # slowly implement a more rigourous check ignore = E, C, W, D, F841 builtins = c, get_config exclude = .github, docs stravalib-1.3.0/.github/000077500000000000000000000000001442076457100150545ustar00rootroot00000000000000stravalib-1.3.0/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000037241442076457100206630ustar00rootroot00000000000000closes #issue-this-closes-here *The text above will ensure the issue will be closed when this PR is merged* ## Description ## Type of change Select the statement best describes this pull request. - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update - [ ] This is a documentation update - [ ] Other (please describe) ## Does your PR include tests If you are fixing a bug or adding a feature, we appreciate (but do not require) tests to support whatever fix of feature you're implementing. - [ ] Yes - [ ] No, i'd like some help with tests - [ ] This change doesn't require tests ## Did you include your contribution to the change log? - [ ] Yes, `changelog.md` is up-to-date. *If you are having a hard time getting the tests to run correctly feel free to ping one of the maintainers here!* stravalib-1.3.0/.github/PULL_REQUEST_TEMPLATE/000077500000000000000000000000001442076457100203335ustar00rootroot00000000000000stravalib-1.3.0/.github/PULL_REQUEST_TEMPLATE/release-pull-request-template.md000066400000000000000000000031141442076457100265450ustar00rootroot00000000000000# Stravalib Release Pull Request Template Please use this template when you are preparing to make a release to stravalib * [An overview of our release workflow can be found in our documentation.](https://stravalib.readthedocs.io/contributing/build-release-guide.md) * [Before making a release be sure to check out test PyPI](https://pypi.org/project/stravalib/) to ensure that the build is working properly. ## Release checklist - [ ] Be sure to clearly specify what version you are bumping to in the PR title: Example: Bump to version x.x - [ ] Add the version of this release to our changelog - [ ] Organize items changes under the new version in groups as follows: Added, Fixed and Changed - [ ] Add all contributors to the release below those sections - [ ] Wait for a maintainer to approve the pull request. Then merge! You are now ready to create the release. The changelog should look something like this: ``` ## Unreleased ## New version here: e.g. v1.0.0 ### Added * Add: Add an option to mute Strava activity on update (@ollyajoke, #227) ### Fixed * Fix: add new attributes for bikes according to updates to Strava API to fix warning message (@huaminghuangtw, #239) ### Changed * Change: Improved unknown time zone handling (@jsamoocha, #242) ### Contributors to this release @jsamoocha, @yihong0618, @tirkarthi, @huaminghuangtw, @ollyajoke, @lwasser ``` Once this PR is merged you are ready to - [ ] Create a tagged release on GitHub using the same version that you merged in the changelog added here - [ ] When you publish that release, the GitHub action to push to PyPI will be invoked. stravalib-1.3.0/.github/workflows/000077500000000000000000000000001442076457100171115ustar00rootroot00000000000000stravalib-1.3.0/.github/workflows/build-docs.yml000066400000000000000000000027231442076457100216650ustar00rootroot00000000000000name: Build Documentation on: pull_request: push: branches: - master jobs: build-doc: runs-on: ubuntu-latest env: PYTHON-VERSION: "3.10" steps: - uses: actions/checkout@v3 - name: Setup Python uses: actions/setup-python@v4 with: python-version: ${{ env.PYTHON-VERSION }} - name: Upgrade pip run: | # install pip=>20.1 to use "pip cache dir" python3 -m pip install --upgrade pip - name: Set Variables id: set_variables shell: bash run: | echo "PY=$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" >> $GITHUB_OUTPUT echo "PIP_CACHE=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v3 with: path: ${{ steps.set_variables.outputs.PIP_CACHE }} key: ${{ runner.os }}-pip-${{ steps.set_variables.outputs.PY }} - name: Install dependencies run: | python3 -m pip install -r ./requirements.txt python3 -m pip install . - name: Build docs & linkcheck run: | # Build html and link check make -C docs - name: Print doc link failures in the output.txt file if: success() || failure() run: | cat docs/_build/linkcheck/output.txt | while read line do echo -e "$line \n" done stravalib-1.3.0/.github/workflows/build-test.yml000066400000000000000000000044251442076457100217150ustar00rootroot00000000000000name: Pytest unit/integration on: pull_request: push: branches: - master # Use bash by default in all jobs defaults: run: shell: bash jobs: build-test: name: Test Run (${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ "ubuntu-latest", "macos-latest", "windows-latest" ] python-version: [ "3.8", "3.9", "3.10", "3.11" ] steps: - uses: actions/checkout@v3 with: # fetch more than the last single commit to help scm generate proper version fetch-depth: 20 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Set Variables id: set_variables shell: bash run: | echo "PY=$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" >> $GITHUB_OUTPUT echo "PIP_CACHE=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v3 with: path: ${{ steps.set_variables.outputs.PIP_CACHE }} key: ${{ runner.os }}-pip-${{ steps.set_variables.outputs.PY }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt python -m pip install -r requirements-build.txt # Install virtualenv to ensure this runs on window python3 -m pip install "build[virtualenv]" - name: List installed packages run: python -m pip freeze # Need tags for setuptools_scm to provide a proper version - name: Fetch git tags run: git fetch origin 'refs/tags/*:refs/tags/*' - name: Build package run: | # Build package python3 -m build - name: Install package run: | # Install package from the built wheel python -m pip install --no-deps dist/*.whl - name: Run tests with pytest run: | make test - name: Upload coverage to Codecov if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9'}} uses: codecov/codecov-action@v3 stravalib-1.3.0/.github/workflows/check-strava-api.yml000066400000000000000000000017271442076457100227650ustar00rootroot00000000000000name: Check Strava API on: schedule: - cron: '0 0 * * *' workflow_dispatch: jobs: update-model: name: Update Model runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Fetch API run: curl https://developers.strava.com/swagger/swagger.json > stravalib/tests/resources/strava_swagger.json - name: Fetch API Schema uses: stravalib/strava_swagger2pydantic@v1 with: model_file: 'stravalib/strava_model.py' - name: Create Pull Request uses: peter-evans/create-pull-request@v4 with: add-paths: | stravalib commit-message: Strava API Change branch: api-change delete-branch: true title: '[CHANGE] Strava API Change' body: | There were changes in the Strava API: [Please edit this comment to indicate what has changed] - [ ] The changelog is updated (only when necessary) stravalib-1.3.0/.github/workflows/push-pypi.yml000066400000000000000000000032621442076457100215750ustar00rootroot00000000000000name: Publish to PyPI on: release: types: [published] push: branches: - master jobs: build-publish: runs-on: ubuntu-latest # This action only needs to run in the base repository if: github.repository_owner == 'stravalib' steps: - name: Checkout uses: actions/checkout@v3 with: # So scm can view previous commits fetch-depth: 100 # Need the tags so that setuptools-scm can form a valid version number - name: Fetch git tags run: git fetch origin 'refs/tags/*:refs/tags/*' - name: Setup Python uses: actions/setup-python@v2 with: python-version: "3.9" - name: Install requirements run: | python -m pip install -r requirements-build.txt pip list - name: Build package run: | python3 -m build echo "" echo "Generated files:" ls -lh dist/ - name: Check the archives run: twine check dist/* - name: Publish package on test PyPI on merge # Test push to test pypi on merge to master if: github.event_name == 'push' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.TEST_PYPI_TOKEN }} repository_url: https://test.pypi.org/legacy/ # Allow existing releases on test PyPI without errors. # NOT TO BE USED in PyPI! skip_existing: true - name: Publish package to PyPI # Only publish to real PyPI on release if: github.event_name == 'release' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_TOKEN }} stravalib-1.3.0/.gitignore000066400000000000000000000006061442076457100155060ustar00rootroot00000000000000env*/ build/ dist/ *.pyc .coverage # This will remove our ability to add CI in the .github dir #.* .DS_Store .idea/ *.egg-info/ *.egg/ docs/_build/ test.ini stravalib/_version_generated.py #*~ examples/strava-oauth/settings.cfg # Code cov .coverage # so people don't mistakenly delete and submit a pr with these stravalib/tests/test.ini-example stravalib/tests/test.ini stravalib/docs/api stravalib-1.3.0/.pre-commit-config.yaml000066400000000000000000000037631442076457100200060ustar00rootroot00000000000000# pre-commit is a tool that you run locally # to perform a predefined set of tasks manually and/or # automatically before git commits are made. # Here we are using pre-commit with the precommit.ci bot to implement # code fixes automagically in pr's. You will still want to install pre-commit # to run locally # Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level # To run on a pr, add a comment with the text "pre-commit.ci run" # Common tasks # # - Run on all files: pre-commit run --all-files # - Register git hooks: pre-commit install --install-hooks ci: autofix_prs: false skip: [flake8] autofix_commit_msg: | '[pre-commit.ci 🤖] Apply code format tools to PR' autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate" # Update hook versions every quarter (so we don't get hit with weekly update pr's) autoupdate_schedule: weekly autoupdate_branch: "" default_stages: [commit] repos: # Misc commit checks - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available hooks: # Autoformat: Makes sure files end in a newline and only a newline. - id: end-of-file-fixer # Lint: Check for files with names that would conflict on a # case-insensitive filesystem like MacOS HFS+ or Windows FAT. - id: check-case-conflict - id: trailing-whitespace # Linting: Python code (see the file .flake8) - repo: https://github.com/PyCQA/flake8 rev: "6.0.0" hooks: - id: flake8 # Black for auto code formatting - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black entry: bash -c 'black "$@"; git add -u' -- language_version: python3.10 args: ["--line-length=79"] - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: - id: isort entry: bash -c 'isort "$@"; git add -u' -- files: \.py$ args: ["--profile", "black", "--filter-files"] stravalib-1.3.0/.readthedocs.yml000066400000000000000000000002501442076457100165770ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/conf.py formats: all python: version: 3.8 install: - method: pip path: . - requirements: requirements.txt stravalib-1.3.0/CODE_OF_CONDUCT.md000066400000000000000000000125711442076457100163210ustar00rootroot00000000000000 # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement by emailing leah at pyopensci.org . All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations stravalib-1.3.0/CONTRIBUTING.md000066400000000000000000000176221442076457100157550ustar00rootroot00000000000000# How to Contribute to Stravalib **Thank you for considering contributing to stravalib!** This is a community-driven project. It's people like you that make it useful and successful. We welcome contributions of all kinds. Below are some of the ways that you can contribute to `stravalib`: * Submit bug reports and feature requests * Write tutorials or examples * Fix typos and improve the documentation * Submit code fixes [Please read our development guide](https://stravalib.readthedocs.io/en/latest/contributing/development-guide.html) if you are interested in submitting a pull request to suggest changes to our code or documentation. ## Contribution ground rules The `stravalib` maintainers work on `stravalib` in their free time because they love the package's contribution to the Python ecosystem. As such we value contributions but also value respectful interactions with `stravalib` users. **Please be considerate and respectful of others** in all of your communications in this repository. Everyone must abide by our [Code of Conduct](https://github.com/stravalib/stravalib/blob/master/CODE_OF_CONDUCT.md). Please read it carefully. Our goal is to maintain a diverse community of `stravalib` users and contributors that's pleasant for everyone. ## How to start contributing to stravalib If you are thinking about submitting a change to stravalib documentation or code, please start by [submitting an issue in our GitHub repository](https://github.com/stravalib/stravalib/issues/). We will use that issue to communicate with you about: 1. Whether the change is in scope for the project 2. Any obstacles that you need help with or clarification on. ### Want to submit a pull request with changes to our code or documentation? We welcome changes to stravalib through pull requests. Before you submit a pull request please be sure to: 1. Read this document fully 2. Submit an issue about the change as discussed below and 2. [Read our development guide](https://stravalib.readthedocs.io/en/latest/contributing/development-guide.html) ### How to report a bug in stravalib or typo in our documentation Found a bug? Or a typo in our documentation? We want to know about it! To report a bug or a documentation issue, please submit an issue on GitHub. If you are submitting a bug report, **please add as much detail as you can about the bug**. Remember: the more information we have, the easier it will be for us to solve your problem. ### Documentation fixes If you're browsing the documentation and notice a typo or something that could be improved, please let us know by creating an issue. We also welcome you to submit a documentation fix directly to us using a pull request to our repository. ## An overview of our stravalib git / GitHub workflow We follow the [git pull request workflow](https://www.asmeurer.com/git-workflow/) to make changes to our codebase. Every change made goes through a pull request, even our own, so that our [continuous integration](https://en.wikipedia.org/wiki/Continuous_integration) services can check that the code is up to standards and passes all of our tests. This workflow allows our *master* branch to always be stable. ### General guidelines for pull requests (PRs): * **Open an issue first** describing what you want to do. If there is already an issue that matches your PR, leave a comment there instead to let us know what you plan to do. Be as specific as you can in the issue. * Each pull request should contain a **small** and logical collection of changes directly related to the issue that you opened. * Larger changes should be broken down into smaller components and integrated separately. * Bug fixes should be submitted in separate PRs. * Describe what your pull request changes and *why* this is a good thing (or refer to the issue that you opened if it contains that information). Be as specific as you can. * Do not commit changes to files that are irrelevant to your feature or bugfix (eg: `.gitignore`, IDE project files, etc). * Write descriptive commit messages that describe what your change is. * Be willing to accept feedback and to work on your code through a pull request. We don't want to break other users' code, so care must be taken not to introduce bugs. * Be aware that the pull request review process is not immediate. The time that it takes to review a pull request is generally proportional to the size of the pull request. Larger pull requests may take longer to review and merge. ### Testing your code Automated testing ensures that our code is as free of bugs as it can be. It also lets us know immediately if a change that we make breaks any other part of the code. All of our test code and data are stored in the `tests` directory within the `stravalib` package directory. We use the [pytest](https://docs.pytest.org/en/latest/) framework to run the test suite. If you are submitting a code fix or enhancement, and know how to write tests, please include tests for your code in your pr. This helps us ensure that your change doesn't break any of the existing functionality. Tests also help us be confident that we won't break your code in the future. If you're **new to testing**, please review existing test files for examples of how to create tests. **Don't let the tests keep you from submitting your contribution!** If you're not sure how to do this or are having trouble, submit your pull request anyway. We will help you create the tests and sort out any kind of problem during code review. You can learn more about our test suite in the development guide. However, if you wish to run the test suite locally, you can use: ```bash make test ``` ### Test coverage We use `codecov` implemented through the `pytest-cov` extension to sphinx to track `stravalib`'s test % coverage. When you submit a pull request, you will see how that pull request affects our package's total test coverage. ### Documentation Our documentation is in the `doc` folder. We use [sphinx](https://www.sphinx-doc.org/en/master/) to build the web pages from these sources. To build the HTML files: ```bash make -C docs html ``` To serve documentation locally use: ```bash make -C docs serve ``` You can learn more about our documentation infrastructure in our [development guide](https://stravalib.readthedocs.io/en/latest/contributing/development-guide.html). ### Code Review and issue response timeline After you've submitted a pull request or an issue, you should expect to see at the minimum, a comment within a couple of days to a week depending on how busy the maintainers are at that time. If you submitted a pull request, we may suggest some changes, improvements or alternative approaches. Some things that will increase the chance that your pull request is accepted quickly: * Write a good and detailed description of what the PR does. * Write tests for the code you wrote/modified. * Readable code is better than clever code (even with comments). * Write documentation for your code (docstrings) and leave comments explaining the *reason* behind non-obvious things. * Include an example of new features in the gallery or tutorials. * Follow the [numpy style guide](https://numpydoc.readthedocs.io/en/latest/format.html) for documentation and docstrings. * Run the automatic code formatter and style checks. All pull requests are automatically tested using workflows in GitHub Actions. Our tests include: * running the test suite * testing the documentation build (which includes API documentation created from docstrings) * running code format and syntax tests for code formatters (TODO: code formatters will be added to stravalib soon!) When you submit a pull request, you will see whether the tests ran or failed. You will also see the resulting % code coverage based upon your pull request. Please try to ensure that all tests pass (Green checks) in your pull request before requesting a review from maintainers. If you have any trouble with the GitHub action tests, please leave a comment in the Pull Request or open an Issue. We will do our best to help you. stravalib-1.3.0/LICENSE.txt000066400000000000000000000261361442076457100153470ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. stravalib-1.3.0/MANIFEST.in000066400000000000000000000001401442076457100152450ustar00rootroot00000000000000recursive-include docs * recursive-include stravalib/tests/resources * include requirements.txt stravalib-1.3.0/Makefile000066400000000000000000000017711442076457100151620ustar00rootroot00000000000000TESTDIR=tmp-test-dir-stravalib PYTEST_ARGS=--cov stravalib stravalib/tests/unit stravalib/tests/integration help: @echo "Commands:" @echo "" @echo " install install package in editable mode" @echo " test run the test suite (including doctests) and report coverage" @echo " build build source and wheel distributions" @echo " clean clean up build and generated files" @echo "" build: python -m build install: python -m pip install --no-deps -e . test: # Create tmp dir to ensure that tests are run on the installed version of stravalib mkdir -p $(TESTDIR) cd $(TESTDIR); python -m pytest $(PYTEST_ARGS) $(PROJECT) rm -r $(TESTDIR) ## Clean up all unneeded files and directories and things that shouldn't be under version control clean: find . -name "*.pyc" -exec rm -v {} \; find . -name "*.orig" -exec rm -v {} \; find . -name ".coverage.*" -exec rm -v {} \; rm -rvf build dist MANIFEST *.egg-info __pycache__ .coverage .cache .pytest_cache $(PROJECT)/_version_generated.py stravalib-1.3.0/README.md000066400000000000000000000171261442076457100150020ustar00rootroot00000000000000# Welcome to stravalib [![DOI](https://zenodo.org/badge/8828908.svg)](https://zenodo.org/badge/latestdoi/8828908) ![PyPI](https://img.shields.io/pypi/v/stravalib?style=plastic) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/stravalib?style=plastic) [![Documentation Status](https://readthedocs.org/projects/stravalib/badge/?version=latest)](https://stravalib.readthedocs.io/en/latest/?badge=latest) ![Package Tests Status](https://github.com/stravalib/stravalib/actions/workflows/build-test.yml/badge.svg) ![PyPI - Downloads](https://img.shields.io/pypi/dm/stravalib?style=plastic) [![codecov](https://codecov.io/gh/stravalib/stravalib/branch/master/graph/badge.svg?token=sHbFJn7epy)](https://codecov.io/gh/stravalib/stravalib) The **stravalib** Python package provides easy-to-use tools for accessing and downloading Strava data from the Strava V3 web service. Stravalib provides a Client class that supports: * Authenticating with stravalib * Accessing and downloading strava activity, club and profile data * Making changes to account activities It also provides support for working with date/time/temporal attributes and quantities through the [Python Pint library](https://pypi.org/project/Pint/). ## Dependencies * Python 3.8+ * Setuptools for installing dependencies * Other Python libraries (installed automatically when using pip): requests, pytz, pint, arrow, pydantic ## Installation The package is available on PyPI to be installed using `pip`: `pip install stravalib` ## How to Contribute to Stravalib ### Get Started! Ready to contribute? Here's how to set up Stravalib for local development. 1. Fork the repository on GitHub -------------------------------- To create your own copy of the repository on GitHub, navigate to the `stravalib/stravalib `_ repository and click the **Fork** button in the top-right corner of the page. 2. Clone your fork locally -------------------------- Use ``git clone`` to get a local copy of your stravalib repository on your local filesystem:: $ git clone git@github.com:your_name_here/stravalib.git $ cd stravalib/ 3. Set up your fork for local development ----------------------------------------- The docs for this library are created using `sphinx`. To build the html version of the documentation, use the command: `$ make -C docs html` ### Building from sources To build the project locally in editable mode, access the project root directory and run: ```bash $ pip install -e . ``` To execute **unit - or integration tests** you will need to run ```bash $ make test ``` ## Local Tests To run **end-to-end** tests you will need to rename *test.ini-example* (which you can find **/stravalib/tests/) to *test.ini* In *test.ini* provide your *access_token* and *activity_id*. Now you can run ``` shell$ pytest stravalib/tests/functional ``` ### Pull Requests and tests Please add tests that cover your changes, these will greatly reduce the effort of reviewing and merging your Pull Requests. In case you need it, there's a pytest fixture `mock_strava_api` that is based on `RequestsMock` from the `responses` package. It prevents requests being made to the actual Strava API and instead registers responses that are based on examples from the published Strava API documentation. Example usages of this fixture can be found in the `stravalib.tests.integration` package. ## Basic Usage Please take a look at the source (in particular the stravalib.client.Client class, if you'd like to play around with the API. Most of the Strava API is implemented at this point; however, certain features such as streams are still on the to-do list. ### Authentication In order to make use of this library, you will need to create an app in Strava which is free to do. [Have a look at this tutorial for instructions on creating an app with Strava - we will be updating our docs with this information soon.](https://medium.com/analytics-vidhya/accessing-user-data-via-the-strava-api-using-stravalib-d5bee7fdde17) **NOTE** We will be updating our documentation with clear instructions to support this in the upcoming months Once you have created your app, stravalib have several helper methods to make authentication easier. ```python from stravalib.client import Client client = Client() authorize_url = client.authorization_url(client_id=1234, redirect_uri='http://localhost:8282/authorized') # Have the user click the authorization URL, a 'code' param will be added to the redirect_uri # ..... # Extract the code from your webapp response code = requests.get('code') # or whatever your framework does token_response = client.exchange_code_for_token(client_id=1234, client_secret='asdf1234', code=code) access_token = token_response['access_token'] refresh_token = token_response['refresh_token'] expires_at = token_response['expires_at'] # Now store that short-lived access token somewhere (a database?) client.access_token = access_token # You must also store the refresh token to be used later on to obtain another valid access token # in case the current is already expired client.refresh_token = refresh_token # An access_token is only valid for 6 hours, store expires_at somewhere and # check it before making an API call. client.token_expires_at = expires_at athlete = client.get_athlete() print("For {id}, I now have an access token {token}".format(id=athlete.id, token=access_token)) # ... time passes ... if time.time() > client.token_expires_at: refresh_response = client.refresh_access_token(client_id=1234, client_secret='asdf1234', refresh_token=client.refresh_token) access_token = refresh_response['access_token'] refresh_token = refresh_response['refresh_token'] expires_at = refresh_response['expires_at'] ``` ### Athletes and Activities (This is a glimpse into what you can do.) ```python # Currently-authenticated (based on provided token) athlete # Will have maximum detail exposed (resource_state=3) curr_athlete = client.get_athlete() # Fetch another athlete other_athlete = client.get_athlete(123) # Will only have summary-level attributes exposed (resource_state=2) # Get an activity activity = client.get_activity(123) # If activity is owned by current user, will have full detail (resource_state=3) # otherwise summary-level detail. ``` ### Streams Streams represent the raw data of the uploaded file. Activities, efforts, and segments all have streams. There are many types of streams, if activity does not have requested stream type, returned set simply won't include it. ```python # Activities can have many streams, you can request n desired stream types types = ['time', 'latlng', 'altitude', 'heartrate', 'temp', ] streams = client.get_activity_streams(123, types=types, resolution='medium') # Result is a dictionary object. The dict's key are the stream type. if 'altitude' in streams.keys(): print(streams['altitude'].data) ``` ### Working with Units stravalib uses the [python Pint library](https://pypi.org/project/Pint/) to facilitate working with the values in the API that have associated units (e.g. distance, speed). You can use the pint library directly or through the `stravalib.unithelper` module for shortcuts ```python activity = client.get_activity(96089609) assert isinstance(activity.distance, unithelper.Quantity) print(activity.distance) # 22530.80 m # Meters!? from stravalib import unithelper print(unithelper.miles(activity.distance)) # 14.00 mi # And to get the number: num_value = float(unithelper.miles(activity.distance)) # Or: num_value = unithelper.miles(activity.distance).num ``` ## Still reading? The [published sphinx documentation](https://stravalib.readthedocs.io/) provides much more. stravalib-1.3.0/changelog.md000066400000000000000000000215411442076457100157700ustar00rootroot00000000000000# Change Log ## Unreleased ## v1.3.0 ### Added - Add: Adds RPE to activity model (@jsamoocha, #355) - Add: support sport_type in client.update_activitiy() (@think-nice-things, #360) ### Fixed - Fix: Move to numpy style docstrings & add black (@lwasser, #365) ### Deprecated - The `activity_type` parameter in the client method `update_activity()` is deprecated and should be replaced by `sport_type`. ### Contributors to this release @jsamoocha, @lwasser, @think-nice-things ## v1.3.0rc0 ### Added - Adds Strava API changes, and datamodel-code-generator bug fix (@jsamoocha, #333) - Add: Replace full legacy model with extensions from the generated pydantic model (@jsamoocha, #324) - Add: Add support for lazy loading related entities (@jsamoocha, #322) - Add: Add support for nested model attributes(@jsamoocha, #316) - Add: replaces implementations for the classes Club, Gear, ActivityTotals, AthleteStats, and Athlete by the generated Pydantic model & backwards compatibility (@jsamoocha, #315) - Add: Workflow for updating strava model when the API changes (@jsamoocha, #302) - Add: `pydantic_autodoc` to sphinx build and reconfigure api structure - p1 (@lwasser, #326) ### Fixed - Fix: Corrects attribute lookup for enum values (@jsamoocha,#329) ### Deprecated - The `BaseEntity` methods `deserialize()`, `from_dict()`, and `to_dict()` are deprecated and will raise a `DeprecationWarning` when they're used. They should be replaced by the pydantic methods `parse_obj()` and `dict()` or `json()`. ### Removed - The complete `attributes` module - All the abstract entity types (e.g. `IdentifiableEntity`, `LoadableEntity`) from the `model` module - Constants used for activity types such as `Activity.RIDE` - `HeartrateActivityZone`, `PowerActivityZone`, `PaceActivityZone` as subtypes of `BaseActivityZone` (the latter is retained) - Everything related to segment leaderboards as this is not supported by Strava anymore ### Contributors to this release @jsamoocha, @lwasser, @oliverkurth ## v1.2.0 ### Added - Add: Upload photo to activity (@gitexel, #318) - Add: Support uploading `activity_file` object with type `bytes` (@gitexel, #308) - Add: Pre-commit hook + instructions and configure precommit.ci bot (@lwasser, #293) ### Fixed - Fix: Internal warnings should be ignored in tests (@jsamoocha, #319) - Fix: `setuptools_scm` bug when installing stravalib remotely via GitHub (@lwasser, #331) - Fix: fix LatLon unmarshal from string type (@oliverkurth, #334) - Fix: allows arithmetic and comparison between multiple quantities (@jsamoocha, #335) ### Contributors to this release @oliverkurth, @gitexel, @jsamoocha, @lwasser ## v1.1.0 ### Added - Add: Development & build/release guide to documentation, edit button to documentation theme, pr template for release (@lwasser, #289) - Add: Integration tests for /routes/{id} and /segments/starred (GET) (@jsamoocha, #250 (partial)) - Add: Add integration tests for all POST/PUT client methods (@jsamoocha, #250 (partial)) - Add: code cov to test suite (@lwasser, #262) - Add: add code of conduct to the repo, update contributing guide + readme badges (@lwasser, #269, #274) - Add: pull request templates for regular pr and release (@lwasser, #294) - Add: Support for python 3.11 ### Fixed - Fix: Move docs to `furo` theme, add `myst` support for markdown, include CONTRIBUTING.md in documentation, enhance intro documentation page and add linkcheck to docs build (@lwasser, #276) - Fix: deprecated set-output command in actions build (@yihong0618, #272) - Fix: Add readthedocs config file to ensure build installs using pip (@lwasser, #270) ### Changed - Change: Replace `units` dependency by `pint` (@jsamoocha, #281) ### Removed - Remove: Support for python 3.7 ### Contributors to this release @lwasser, @yihong0618, @jsamoocha ## v1.0.0 ### Added - Add: Add an option to mute Strava activity on update (@ollyajoke, #227) - Add Update make to build and serve docs and also run current tests (@lwasser,#263) - Add: Move package to build / `setuptools_scm` for version / remove setup.py and add CI push to pypi (@lwasser, #259) ### Fixed - Fix: add new attributes for bikes according to updates to Strava API to fix warning message (@huaminghuangtw, #239) - Fix: Minor bug in PyPI push and also streamlined action build (@lwasser, #265) - Fix: `get_athlete` w new attrs for shoes given strava updates to API (@lwasser, #220) - Fix: Refactor deprecated unittest aliases for Python 3.11 compatibility (@tirkarthi, #223) - Patch: Update readme and fix broken links in docs (@lwasser, #229) ### Changed - Change: Improved unknown time zone handling (@jsamoocha, #242) - Change: Refactor test suite and implement Ci for tests (@jsamoocha, #246) - Change: Remove support for python 2.x (@yihong0618, #254) - Change: Overhaul of documentation, fix links and CI build (@lwasser, #222) ### Contributors to this release @jsamoocha, @yihong0618, @tirkarthi, @huaminghuangtw, @ollyajoke, @lwasser ## 0.10.4 - Fix to unicode regression (@hozn, #217) ## 0.10.3 - Fixes IndexErrors when deserializing empty lists as GPS locations (@hozn, #216) - Fix a few fields in Activity model (@hozn, #201, #214, #207) - deal with tzname without offset and timedelta in string format (@hozn, #195) - Update to docs and repr (@hozn, #200, #205, #206) - Now webhooks use the same domain as the rest of API. (@hozn, #204) - Setting rate_limit_requests=False in Client causes error (@hozn, #157) ## 0.10.2 - More fixes to new new authorization scopes (@hozn, #168) - Added an example oauth app and some small docs updates. - Changed missing-attribute warnings to be debug-level logs. ## 0.10.1 - Fixes of authorization_url / new scopes for new oauth (@hozn, #163, #165) ## 0.10.0 - Implementation of Strava's new auth. (@hozn, #162, #163) ## 0.9.4 - Version bump for dup file upload to pypi. :-[ ## 0.9.3 - Fix mutable parma defaults in rate-limiter util functions (@hozn, #155) - Add the missing subscription_permissions attr to Athlete (@hozn, #156) ## 0.9.2 - Fix for pip 0.10.0 (@paulte, #149, #150) ## 0.9.1 - Auto-configure the rate limits (not just usage) from response headers. (@hozn, #142) ## 0.9.0 - More API changes to reflect the big privacy changes from Strava. (@hozn, #139, #140) - Fix to kom_type attribute (@hozn, #138) ## 0.8.0 - Fixes to segment leaderboard models for Strava's API BREAKING CHANGE (@hozn, #137) (See https://groups.google.com/forum/#!topic/strava-api/SsL2ytxtZng) - Return ObjectNotFound and AccessUnauthorized HTTPError subclasses for 404 and 401 errors respectively (@hozn, #134) - Return None when there are no activity streams (@hozn, #118) ## 0.7.0 - Updated Activity for new attributes (@hozn, #115, #122) - New segment attributes (@JohnnyLChang, #106) - Streams for a route (@drixselecta, #101) - Activity Uploader improvements (@bwalks, #119) - Added to_dict() method to model objects (@hozn, #127) - Added get_athlete_starred_segments (@wjazdbitu, #117) - Fixed glitches in activity.laps (@hozn, #112) - Fixed bug in club.members (@hozn, #110) ## 0.6.6 - Fix for delete_activity (@jonderwaater, #99) ## 0.6.5 - Updated ActivityPhoto model to support native photos and reverted get_activity_photos behavior for backwards compatibility (@hozn, #98) - Added missing Club attributes (MMI) (@hozn, #97) ## 0.6.4 - Added support for undocumented inclusion of laps in activity details. (@hozn, #96) - Added missing parameter for get_activity_photos (@hozn, #94) - Added missing activyt pr_count attribute (@Wilm0r, #95) - add "starred" property on SegmentExplorerResult (@mdarmetko, #92) ## 0.6.3 - Fixed update_activity to include description (@hozn, #91) ## 0.6.2 - More Python3 bugfixes ## 0.6.1 - Python3 bugfixes (@Tafkas, @martinogden) - Added delete_activity - added context_entries parameter to get_segment_leaderboard method (@jedman) ## 0.6.0 - Use (require) more modern pip/setuptools. - Full Python 3 support (using Six). (@hozn, #69) - Webhooks support (thanks to loisaidasam) (@hozn, #77) - explore_segments bugfix (@hozn, #71) - General updates to model/attribs (@hozn, #64, #73, etc.) ## 0.5.0 - Renamed `Activity.photos` property to `full_photos` due to new conflict with Strava API (@hozn, #45) ## 0.4.0 - Supporting new/removed attribs in Strava API (@hozn, #41, #42) - Added support for joining/leaving clubs (@hozn, #43) - Respect time zones in datetime objects being converted to epochs. (@hozn, #44) ## 0.3.0 - Activity streams data (Ghis) - Friends/followers model attributes (Ghis) - Support for photos (Ghis) - Updates for new Strava exposed API attributes (@hozn) ## 0.2.2 - Fixed the \_resolve_url to not assume running on **nix** system. ## 0.2.1 - Changed Activity.gear to be a full entity attribute (Strava API changed) ## 0.2.0 - Added core functionality for Strava API v3. - Mostly redesigned codebase based on drastic changes in v3 API. - Dropped support for API v1, v2 and the "scrape" module. ## 0.1.0 - First proof-of-concept (very alpha) release. stravalib-1.3.0/docs/000077500000000000000000000000001442076457100144445ustar00rootroot00000000000000stravalib-1.3.0/docs/Makefile000066400000000000000000000035641442076457100161140ustar00rootroot00000000000000# Makefile for Sphinx documentation # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXAUTOGEN = sphinx-autogen BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(SPHINXOPTS) . .PHONY: help clean html # This runs if you run make -C docs from the root dir # This has to go above help to work by default all: linkcheck help: @echo "Please use \`make ' where is one of" @echo " all generate the complete documentation api and regular" @echo " html make only the HTML files from the existing rst sources" @echo " clean clean all local doc directories out" @echo " serve serve the docs locally using sphinx autobuild" @echo " linkcheck run sphinx link check on the docs and produce output w broken links" # Run: make -C docs clean clean: rm -rf $(BUILDDIR)/html/* rm -rf $(BUILDDIR)/doctrees rm -rf $(BUILDDIR)/linkcheck rm -rf reference/api rm -rf .ipynb_checkpoints html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." linkcheck: html $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." ## Serve live docs in a browser serve: pwd sphinx-autobuild ../docs _build/html stravalib-1.3.0/docs/_static/000077500000000000000000000000001442076457100160725ustar00rootroot00000000000000stravalib-1.3.0/docs/_static/keepme000066400000000000000000000000001442076457100172510ustar00rootroot00000000000000stravalib-1.3.0/docs/_static/stravalib.css000066400000000000000000000007451442076457100206010ustar00rootroot00000000000000html, body { font-size: 1.02rem; } .admonition { margin-top: 40px; margin-bottom: 40px; } h1 { margin-top: 50px; margin-bottom: 40px; } h2 { margin-top: 60px; } h3 { margin-top: 40px} figcaption .caption-text { text-align: left!important; } figure { margin-top: 60px!important; margin-bottom: 60px!important; } figcaption { font-size: .9em; font-weight: bold; } .admonition p { font-size: 1.1em; font-weight: bold; } stravalib-1.3.0/docs/conf.py000066400000000000000000000147171442076457100157550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath("../")) sys.path.insert(0, os.path.abspath("../stravalib")) # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # -- Project information ----------------------------------------------------- import datetime import stravalib # General project info # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. project = "stravalib" # Automagically create the year vs hand code copyright = ( f"{datetime.date.today().year}, The {project} Developers" # noqa: A001 ) # Grab the package version from the version attr if len(stravalib.__version__.split(".")) > 3: version = "dev" else: version = stravalib.__version__ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.napoleon", # Numpy style doc support "sphinx_remove_toctrees", # Remove api generated stubs from doctree "sphinxcontrib.autodoc_pydantic", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.ifconfig", "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx.ext.autosummary", "myst_nb", "sphinx_design", "sphinxext.opengraph", "sphinx_inline_tabs", ] remove_from_toctrees = ["docs/reference/api/*"] # https://autodoc-pydantic.readthedocs.io/en/stable/users/installation.html autodoc_pydantic_model_show_json = True autodoc_pydantic_settings_show_json = False autosummary_generate = True # Colon fence for card support in md myst_enable_extensions = ["colon_fence"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The master toctree document. master_doc = "index" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [ "_build", "stravalib/tests", "stravalib/tests/functional", "stravalib/tests/unit", "stravalib/tests/resources", ] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # The title in the left hand corner of the docs html_title = "Stravalib Docs" # Theme and css html_theme = "pydata_sphinx_theme" # Add edit button to furo theme # Link to our repo for easy PR/ editing html_theme_options = { "header_links_before_dropdown": 4, "use_edit_page_button": True, "show_toc_level": 1, # "navbar_align": "left", # [left, content, right] For testing that the navbar items align properly "github_url": "https://github.com/stravalib/stravalib", "footer_items": ["copyright"], } html_context = { "github_user": "stravalib", "github_repo": "stravalib", "github_version": "master", } html_static_path = ["_static"] # html_css_files = ["stravalib.css"] # Short title for the navigation bar. html_short_title = "Stravalib Python Package Documentation" # Instagram always throws 429 so ignore it linkcheck_ignore = [r"https://www.instagram.com/accounts/login/"] # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "stravalibdoc" # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ("index", "stravalib", "stravalib Documentation", ["Hans Lellelid"], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( "index", "stravalib", "stravalib Documentation", "Hans Lellelid", "stravalib", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. # intersphinx_mapping = {'http://docs.python.org/': None} # Default to using the order defined in source. autodoc_member_order = "bysource" stravalib-1.3.0/docs/contributing/000077500000000000000000000000001442076457100171535ustar00rootroot00000000000000stravalib-1.3.0/docs/contributing/build-release-guide.md000066400000000000000000000110311442076457100233010ustar00rootroot00000000000000# Stravalib build and release guide This page outlines the build structure and release workflow for stravalib. ## Stravalib packaging overview For packaging we use `setuptools` for packaging and the `build` package to create a wheel and distribution for pushing to PyPI. ## Package versioning To keep track of stravalib versioning, we use `setuptools_scm`. Setuptools_scm is a behind the scenes tool that uses the most current tag in the repository to determine what version of the package is being built. `setuptools_scm` creates a `_version_generated.py` file upon build using that tag. ```{warning} If you build the package locally, the `_version_generated.py` file should NEVER be committed to version control. It should be ignored via our `.gitignore` file ``` If you wish to build stravalib locally to check out the .whl and distribution tarball you can use: ``` make build ``` When you run `make build`, it will do a few things 1. it will create a `dist` directory with the wheel and the tarball distributions of the package in it. You can see the version of `stravalib` in the name of those files: ```bash dist/ stravalib-1.0.0.post27-py3-non-any.whl stravalib-1.0.0.post27.tar.gz ``` 2. `make build` also invokes `setuptools_scm` to create a `_version_generated.py` file in the stravalib package directory: ```bash stravalib/ stravalib/ _version_generated,py ``` ## Our PyPI release workflow The entire release workflow is automated and can be completed on fully in the GitHub.com interface if you wish. We follow [semantic version](https://semver.org/) best practices for our release workflow as follows: * MAJOR version when you make incompatible API changes * MINOR version when you add functionality in a backwards compatible manner * PATCH version when you make backwards compatible bug fixes ### How to make a release to PyPI ```{note} The build workflow explained below will run and push to test PyPI on every merge to the master branch of stravalib. Thus before you create a pull request to initiate a new release, please check out stravalib on [test pypi](https://pypi.org/project/stravalib/) to: 1. Make sure that the README file and other elements are rendering properly 2. You can also install the package from test PyPI as an additional check! ``` To make a release: * âœ”ï¸ 1. Determine with the other maintainers what release version we are moving to. This can be done in an issue. * âœ”ï¸ 2. Create a new **pull request** using the release pull request template that does the following: * Organizes the changelog.md unreleased items into added, fixed and changed sections * Lists contributors to this release using GitHub handles * Adds the version number of that specific release. Below you can see an example of what these changelog changes looked like when we bumped to version 1.0 of stravalib. *(Some fo the original change log content is removed to keep this page shorter)* ``` ## Unreleased ## v1.0.0 ### Added * Add: Add an option to mute Strava activity on update (@ollyajoke, #227) * Add Update make to build and serve docs and also run current tests (@lwasser,#263) * Add: Move package to build / `setuptools_scm` for version / remove setup.py and add CI push to pypi (@lwasser, #259) ### Fixed * Fix: add new attributes for bikes according to updates to Strava API to fix warning message (@huaminghuangtw, #239) * Fix: Refactor deprecated unittest aliases for Python 3.11 compatibility (@tirkarthi, #223) * Patch: Update readme and fix broken links in docs (@lwasser, #229) ### Changed * Change: Refactor test suite and implement Ci for tests (@jsamoocha, #246) * Change: Remove support for python 2.x (@yihong0618, #254) ### Contributors to this release @jsamoocha, @yihong0618, @tirkarthi, @huaminghuangtw, @ollyajoke, @lwasser ``` * âœ”ï¸ 3. Once another maintainer approves the pull request, you can merge it. You are now ready to make the actual release. * âœ”ï¸ 4. In GitHub.com go to `Releases` and prepare a new release. When you create that release you can specify the tag for this release. Use `v` in the tag number to maintain consistency with previous releases. This is the ONLY manual step in the release workflow. Be sure to create the correct tag number: example `v1.0.1` for a patch version. Copy the updated changelog information into the body of the release. * âœ”ï¸ 5. Now hit `publish release`. When you publish the release, a GitHub action will be enabled that will: 1. build the wheel and tarball and 2. push the distribution to PyPI! Congratulations! You've just created a release of stravalib! stravalib-1.3.0/docs/contributing/development-guide.md000066400000000000000000000410351442076457100231150ustar00rootroot00000000000000# Development Guide for Anyone Who Wants to Contribute to Stravalib ```{note} * Please make sure that you've read our [contributing guide](how-to-contribute.md) before reading this guide. * If you are looking for information on our package build structure and release workflow please see our build and [release guide](build-release-guide) ``` The steps to get started with contributing to stravalib are below. You will begin by forking and cloning our Github repository. ## Fork and clone the stravalib repository ### 1. Fork the repository on GitHub To create your own copy of the stravalib repository on GitHub, navigate to the [stravalib/stravalib](https://github.com/stravalib/stravalib) repository and click the **Fork** button in the top-right corner of the page. ### 2. Clone your fork locally Next, use ``git clone`` to create a local copy of your stravalib forked repository on your local filesystem: ```bash $ git clone git@github.com:your_name_here/stravalib.git $ cd stravalib/ ``` Once you have cloned your forked repository locally, you are ready to create a development environment. ## Setup a local development environment We suggest that you create a virtual environment on your computer to work on `stravalib`. Below, we show you how to do that using a `conda` environment. However, you are welcome to use pip / `virtualenv` or whatever environment manager that you prefer! ### Create your local development environment using `conda` The instructions below assume that you have a conda enabled Python distribution. Anaconda and miniconda are examples of two conda python distributions. If you are unsure of which distribution to use, [we suggest miniconda](https://docs.conda.io/en/latest/miniconda.html) as it is a lighter weight installation that will minimize environment conflicts given it has fewer packages and tools bundled with it compared to the much larger Anaconda distribution. To begin, install the conda environment. This will create a local conda environment called `stravalib_dev` ```bash $ conda env create -f environment.yml ``` Next, activate the environment. ```bash $ conda activate stravalib_dev ``` Finally install the package dependencies and the `stravalib` package in development / editable mode (`-e`). Editable mode allows you to make updates to the package and test them in realtime. ```bash # install the package requirements $ pip install -r requirements.txt # Install stravalib in editable mode $ pip install -e . ``` ## Architecture Overview ![Stravalib Architecture](../images/stravalib_architecture.png) Stravalib contains the following main components: At the core, a (pydantic) domain model is generated and updated by a bot via pull requests. This model reflects the officially published API specification by Strava and is stored in the module `strava_model.py`. This file should never be edited manually. Instead, the stravalib bot will suggest changes to the model through pull requests that can then be merged by stravalib maintainers. The module `model.py` contains classes that inherit from the official Strava domain model in `strava_model.py`. This module supports custom typing, unit conversion, (de-)serialization behavior, and support for undocumented Strava features. The module `protocol.py` manages the sending of HTTP requests to Strava and handling the received responses (including rate limiting). It is used by methods in `client.py` to de-serialize raw response data into the domain entities in `model.py`. ## Code style and linting We use the following tools to ensure consistent code format that follows the Python Enhancement Protocol (PEP) 008 standards. These standards dictate best practices for Python code readability and consistency: * [black](https://black.readthedocs.io/en/stable/) for consistent code format that generally follows [PEP 8 guidlines](https://peps.python.org/pep-0008/). Because black's default line length is 88 characters, we adjust it to 79 characters in the config [to follow PEP 8 line width guidelines](https://peps.python.org/pep-0008/#maximum-line-length). * [isort](https://pycqa.github.io/isort/): ensure imports are ordered following [PEP 8 import guidelines](https://peps.python.org/pep-0008/#imports) * [flake8](https://flake8.pycqa.org/en/latest/): flake8 is a linter. It identifies other PEP 8 issues in the code that Black will not address including comments that extend beyond 79 characters and doc string line width. It also will identify unused imports and unused but declared variables. For local development, you can use our [`pre-commit` hook setup](https://pre-commit.com/). When installed, the pre-commit hook will run each code format tool, specified in the `pre-commit-config.yaml file`, every time you make a commit to your local clone of stravalib. If your code doesn't "pass" checks for each tool then the following happens: 1. If it's a `black` or `isort` error, the code will be fixed / updated by black and/or isort. 2. If it's a `flake8` error, flake8 will provide you with a list of issues in your code. You will need to fix each individually by hand. ### Setup and run the pre-commit hooks To setup the pre-commit hook locally, run: ``` $ pre-commit install ``` The tools installed will be the ones listed in the **.pre-commit-config.yaml** file. ````{tip} You can run all hooks locally without a commit by using: ```bash $ .git/hooks/pre-commit ``` You can also run a single hook using the following: ``` # Only run isort # pre-commit run isort ``` ```` ### Pre-commit.ci bot We use the `https://pre-commit.ci`, in addition to pre-commit in our local build to manage pull requests. The configuration for this bot can be found in the ci: section of the `pre-commit-config.yaml` file. This bot can run all of the code format hooks on every pull request if it's set to do so. Currently, we have the bot setup to only run when it's asked to run on a PR. To call the bot on a pull request, add the text: `pre-commit.ci run` as a single line comment in the pull request. The bot will automatically run all of the hooks that it is configured to run. ```{tip} If you have an open Pull Request but you need to make some changes locally, and the bot has already run on your pull request and added a commit, you can force push to the pull request to avoid multiple bot commits. To do this simply: * Do not pull down any changes from the pull request, * Commit your changes locally, When you are ready to push your local changes use: `git push origin branch-name-here --force` If you have not yet pulled down pre-commit bot's changes, this will force the branch to be in the same commit state as your local branch. ``` ## Code format and syntax If you are contributing code to `stravalib`, please be sure to follow PEP 8 syntax best practices. ### Docstrings **All docstrings** should follow the [numpy style guide](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard). All functions/classes/methods should have docstrings with a full description of all arguments and return values. ```{warning} This also will be updated once we implement a code styler While the maximum line length for code is automatically set by *Black*, docstrings must be formatted manually. To play nicely with Jupyter and IPython, **keep docstrings limited to 79 characters** per line. ``` ## About the stravalib test suite Tests for stravalib are developed and run using `pytest`. We have two sets of tests that you can run: 1. functional end-to-end test suite: this test set requires an API key to run. 1. unit tests that are setup to run on CI. These tests use mock instances of the API to avoid needed to setup an API key yourself locally. ### Unit - and integration test suite ```{warning} We will add more information about the test suite in the near future. For integration tests that should be run independently from Strava, there's a pytest fixture :func:`~stravalib.tests.integration.conftest.mock_strava_api` that is based on :class:`responses.RequestsMock`. It prevents requests being made to the actual Strava API and instead registers responses that are based on examples from the published Strava API documentation. Example usages of this fixture can be found in the :mod:`stravalib.tests.integration.test_client` module. ``` We have setup the test suite to run on the stravalib package as installed. Thus when running your tests it is critical that you have a stravalib development environment setup and activated with the stravalib package installed from your fork using pip `pip install .` You can run the tests using make as specified below. Note that when you run the tests this way, they will run in a temporary environment to ensure that they are running against the installed version of the package that you are working on. To run the test suite use: ``` make test ``` `make test` does a few things: 1. It create a temporary directory called `tmp-test-dir-stravalib` in which your tests are run. We create this test directory to ensure that tests are being run against the installed version of stravalib (with the most recent local development changes as installed) rather than the flat files located in the GitHub repository. 2. It runs the tests and provides output (see below) 3. Finally it removes the temporary directory ### Functional end-to-end test suite The functional (end-to-end) test suite is set up to hit the STRAVA api. You will thus need an app setup in your Strava account to run the test suite. We recommend that you create a dummy account for this with a single activity to avoid any chances of your data being unintentionally modified. Once you have the app setup and a valid access_token for an account with at least one activity, follow the steps below. 1. Rename the file `stravalib/stravalib/tests/test.ini-example` to `test.ini` 2. Add your API token to the file by replacing: ```bash access_token = xxxxxxxxxxxxxxxx ``` with: ```bash access_token = your-authentication-token-value-here ``` NOTE: this token needs to have write access to your account. We recommend that you create a dummy account to ensure you aren't modifying your actual account data. 3. Add a single activity id to your dummy account using stravalib: ```bash activity_id = a-valid-activity-id-here ``` You are now ready to run the test suite. To run tests on python 3.x run: ```bash $ pytest ``` ### Test code coverage We use [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/) to calculate test coverage. When you run `make test` pytest-cov will provide you with coverage outputs locally. You can ignore the returned values for any files in the `test` directory. Example output from `make test`: ```bash pytest --cov stravalib stravalib/tests/unit stravalib/tests/integration =================================================== test session starts =================================================== platform darwin -- Python 3.8.13, pytest-7.2.0, pluggy-1.0.0 rootdir: /Users/leahawasser/Documents/GitHub/stravalib plugins: cov-4.0.0 collected 105 items stravalib/tests/unit/test_attributes.py ............... [ 14%] stravalib/tests/unit/test_client_utils.py ....... [ 20%] stravalib/tests/unit/test_limiter.py ............. [ 33%] stravalib/tests/unit/test_model.py ....... [ 40%] stravalib/tests/integration/test_client.py ............................................................... [100%] ---------- coverage: platform darwin, python 3.8.13-final-0 ---------- Name Stmts Miss Cover ---------------------------------------------------------------------------- stravalib/__init__.py 2 0 100% stravalib/_version.py 2 0 100% stravalib/_version_generated.py 2 0 100% stravalib/attributes.py 170 19 89% stravalib/client.py 439 180 59% stravalib/exc.py 34 3 91% stravalib/model.py 709 126 82% stravalib/protocol.py 130 39 70% stravalib/unithelper.py 16 1 94% stravalib/util/__init__.py 0 0 100% stravalib/util/limiter.py 122 27 78% ---------------------------------------------------------------------------- TOTAL ``` ### Code coverage reporting on pull requests with codecov We use an integration with [codecov.io](https://about.codecov.io) to report test coverage changes on every pull request. This report will appear in your pull request once all of the GitHub action checks have run. ```{note} The actual code coverage report is uploaded on the GitHub action run on `ubuntu` and `Python 3.9`. When that step in the actions completes, the report will be processed and returned to the pull request. ``` ## Documentation `Stravalib` documentation is created using `sphinx` and the [`furo`](https://pradyunsg.me/furo/quickstart/) theme. `Stravalib` documentation is hosted on [ReadtheDocs](https://readthedocs.org). The final online build that you see on readthedocs happens on the readthedocs website. Our continuous integration GitHub action only tests that the documentation builds correctly. It also tests for broken links. The readthedocs build is configured using the `.readthedocs.yml` file rather than from within the readthedocs interface as recommended by the readthedocs website. The badge below (also on our `README.md` file) tells you whether the readthedocs build is passing or failing. [![Documentation Status](https://readthedocs.org/projects/stravalib/badge/?version=latest)](https://stravalib.readthedocs.io/en/latest/?badge=latest) Currently @hozn, @lwasser and @jsamoocha have access to the readthedocs `stravalib` documentation build Online documentation will be updated on all merges to the master branch of `stravalib`. ### Build documentation locally To build the documentation, first activate your stravalib development environment which has all of the packages required to build the documentation. Then, use the command: ```bash $ make -C docs ``` This command: * Builds documentation * Builds `stravalib` API reference documentation using docstrings within the package * Checks for broken links After running `make -C docs` you can view the built documentation in a web browser locally by opening the following file on your computer: ``` /your-path-to-stravalib-dir/stravalib/docs/_build/html/index.html ``` You can also view any broken links in the output.txt file located here: `/your-path-to-stravalib-dir/stravalib/docs/_build/linkcheck/output.txt` ### Build locally with a live server We use `sphinx-autobuild` to build the documentation in a live web server. This allows you to see your edits automatically as you are working on the text files of the documentation. To run the live server use: ```bash $ make -C docs serve ``` ```{note} There is a quirk with autobuild where included files such as the CHANGELOG will not update live in your local rendered build until you update content on a file without included content. ``` ### Stravalib API Documentation ```{warning} ThIS SECTION WILL BE UPDATED IN THE NEAR FUTURE The API reference is manually assembled in `doc/api/index.rst`. The *autodoc* sphinx extension will automatically create pages for each function/class/module listed there. You can reference classes, functions, and modules from anywhere (including docstrings) using :func:\`package.module.function\`, :class:\`package.module.class\`, or :mod:\`package.module\`. Sphinx will create a link to the automatically generated page for that function/class/module. ``` ### About the documentation CI build Once you create a pull request, GitHub actions will build the docs and check for any syntax or url errors. Once the PR is approved and merged into the master branch of the `stravalib/stravalib` repository, the docs will build and be [available at the readthedocs website](https://stravalib.readthedocs.io/en/latest/). ### Cleanup of documentation and package build files To clean up all documentation build folders and files, run the following command from the root of the `stravalib` directory: ```bash $ make -C docs clean ``` To clean up build files such as the .whl file, and other temporary files creating when building `stravalib` run: ```bash $ make clean ``` stravalib-1.3.0/docs/contributing/documentation-changelog.md000066400000000000000000000000421442076457100242670ustar00rootroot00000000000000 ```{include} ../../changelog.md stravalib-1.3.0/docs/contributing/how-to-contribute.md000066400000000000000000000000451442076457100230650ustar00rootroot00000000000000 ```{include} ../../CONTRIBUTING.md stravalib-1.3.0/docs/contributing/resources-for-new-contributors.md000066400000000000000000000025101442076457100256130ustar00rootroot00000000000000# Contributor resources ## Contributing Code **Is this your first contribution?** Please take a look at these resources to learn about git and pull requests (don't hesitate to ask questions: * [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/). * Aaron Meurer's [tutorial on the git workflow](https://www.asmeurer.com/git-workflow/) * [How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github) If you're new to working with git, GitHub, and the Unix Shell, we recommend starting with the [Software Carpentry](https://software-carpentry.org/) lessons, which are available in English and Spanish: * [Version Control with Git](https://swcarpentry.github.io/git-novice/) / spanish: [Control de versiones con Git](https://swcarpentry.github.io/git-novice-es/) * [The Unix Shell](https://swcarpentry.github.io/shell-novice/) / spanish: [La Terminal de Unix](https://swcarpentry.github.io/shell-novice-es/) ## Additional contribution resources For more information on contributing to open source projects, checkout: * [GitHub's contribution guide](https://docs.github.com/en) is a great starting point if you are new to version control. * The [Zen of Scientific Software Maintenance](https://jrleeman.github.io/ScientificSoftwareMaintenance/) stravalib-1.3.0/docs/get-started/000077500000000000000000000000001442076457100166675ustar00rootroot00000000000000stravalib-1.3.0/docs/get-started/activities.rst000066400000000000000000000055241442076457100215730ustar00rootroot00000000000000.. _activities: Activities ********** Examples of working with activities. Retrieve An Activity ==================== To get a given activity, use the get_activity function and provide activity_id, The function will return a :class:`stravalib.model.Activity` object.:: activity = client.get_activity(207650614) print("type = " + activity.type) Activity object has many basic properties such as type and distance.:: print("type={0.type} distance={1} km".format(activity, unithelper.kilometers(activity.distance))) But also many secondary properties like kudos, comments and photos which follow the following pattern.:: # Number of comments on activity activity.comment_count # print each comment for comment in activity.comments: print("{} : {}".format(comment.athlete.lastname, comment.text)) Activity information -------------------- Most information pertaining to actitity is available directly on the :class:`stravalib.model.Activity` object. Some additional information can be retrieved relevant methods. Below is example of :meth:`stravalib.client.Client.get_activity_streams`:: # Activities can have many streams, you can request desired stream types types = ['time', 'latlng', 'altitude', 'heartrate', 'temp', ] streams = client.get_activity_streams(123, types=types, resolution='medium') # Result is a dictionary object. The dict's key are the stream type. if 'altitude' in streams.keys(): print(streams['altitude'].data) Additionally, activity zones can be retrieved with :meth:`stravalib.client.Client.get_activity_zones` and activity laps can be retrieved with :meth:`stravalib.client.Client.get_activity_laps` . List of Activities ================== Three functions return lists of activities. List the authenticated athlete's activities with :meth:`stravalib.client.Client.get_activities`.:: for activity in client.get_activities(after = "2010-01-01T00:00:00Z", limit=5): print("{0.name} {0.moving_time}".format(activity)) .. tip:: To get activities in oldest to newest, specify a value for the `after` argument. To get newest to oldest use `before` argument. Additionally list the authenticated athlete's friends activities with :meth:`stravalib.client.Client.get_friend_activities`, or list a club member's activities with :meth:`stravalib.client.Client.get_club_activities`. Manage Activities ================= (TODO) =============== ================================================ method doc =============== ================================================ create_activity :meth:`stravalib.client.Client.create_activity` upload_activity :meth:`stravalib.client.Client.upload_activity` update_activity :meth:`stravalib.client.Client.update_activity` =============== ================================================ stravalib-1.3.0/docs/get-started/athletes.rst000066400000000000000000000022351442076457100212340ustar00rootroot00000000000000.. _athletes: Athletes ******** This page is designed to mirror the structure of the documentation at https://developers.strava.com/docs/reference/#api-Athletes and describe the methods for working with athlete data in the Strava API. Retrieve Current Athlete ======================== This is the simplest request. It is provided by the :meth:`stravalib.client.Client.get_athlete` when called with no parameters.:: athlete = client.get_athlete() print("Hello, {}".format(athlete.firstname)) See the :class:`stravalib.model.Athlete` class for details on what is returned. For this method, full detailed-level attribute set is returned. Retrieve Another Athlete ======================== A variation on the above request, this is provided by the :meth:`stravalib.client.Client.get_athlete` when called with an athlete ID.:: athlete = client.get_athlete(227615) print("Hello, {}".format(athlete.firstname)) See the :class:`stravalib.model.Athlete` class for details. only summary-level subset of attributes is returned when fetching information about another athlete. Update Current Athlete ====================== (This is not yet implemented by stravalib.) stravalib-1.3.0/docs/get-started/authenticate-with-strava.rst000066400000000000000000000063361442076457100243560ustar00rootroot00000000000000.. _auth: Authentication and Authorization ******************************** In order to use this library to retrieve information about athletes and actitivies, you will need authorization to do so. `This is a nice tutorial that has information about setting up a free app within STRAVA `_ . If you want a more technical overview, see the `official documentation `_ for a description of the OAuth2 protocol that Strava uses to authenticate users. Requesting Authorization ======================== The :class:`stravalib.client.Client` class provides the :meth:`stravalib.client.Client.authorization_url` method to build an authorization URL which can be clicked on by a user in order to grant your application access to their account data. In its simplest form:: from stravalib import Client client = Client() url = client.authorization_url(client_id=MY_STRAVA_CLIENT_ID, redirect_uri='http://myapp.example.com/authorization') Note that for development, you can use localhost or 127.0.0.1 as the redirect host.:: url = client.authorization_url(client_id=MY_STRAVA_CLIENT_ID, redirect_uri='http://127.0.0.1:5000/authorization') Now you can display the resulting URL in your webapp to allow athletes to authorize your application to read their data. In the /authorization handler, you will need to exchange a temporary code for a temporary access token. :: from stravalib import Client code = request.args.get('code') # e.g. client = Client() token_response = client.exchange_code_for_token(client_id=MY_STRAVA_CLIENT_ID, client_secret=MY_STRAVA_CLIENT_SECRET, code=code) access_token = token_response['access_token'] refresh_token = token_response['refresh_token'] # You'll need this in 6 hours The resulting access_token is valid until the specified expiration time (6 hours, specified as unix epoch seconds `expires_at` field of returned token) or the user explicitly revokes application access. This token can be stored so that you can access the account data the future without requiring re-authorization. Once you have an access token you can begin to perform operations from the perspective of that user. :: from stravalib import Client client = Client(access_token=STORED_ACCESS_TOKEN) client.get_athlete() # Get current athlete details To refresh the token you would call the :meth:`stravalib.client.Client.refresh_access_token` method. :: from stravalib import Client code = request.args.get('code') # e.g. client = Client() token_response = client.refresh_access_token(client_id=MY_STRAVA_CLIENT_ID, client_secret=MY_STRAVA_CLIENT_SECRET, refresh_token=last_refresh_token) new_access_token = token_response['access_token'] See the https://github.com/stravalib/stravalib/tree/master/examples/strava-oauth directory for an example Flask application for fetching a Strava auth token. stravalib-1.3.0/docs/get-started/index.md000066400000000000000000000037311442076457100203240ustar00rootroot00000000000000# Get Started Using Stravalib ```{toctree} :hidden: :caption: Get Started Install Stravalib Overview Authentication Activities Athletes ``` ```{toctree} :hidden: :caption: Contributing :maxdepth: 2 Contributing Guide <../contributing/how-to-contribute> Development Guide <../contributing/development-guide> Build & Release Guide <../contributing/build-release-guide> New Contributor Resources <../contributing/resources-for-new-contributors> Change Log <../contributing/documentation-changelog> ``` ## Install stravalib The package is available on PyPI to be installed using `pip`. ```bash $ pip install stravalib ``` ## Using Stravalib In order to make use of this library, you will need to have access keys for one or more Strava users. [This is a nice tutorial that has information about setting up a free app within STRAVA](https://medium.com/analytics-vidhya/accessing-user-data-via-the-strava-api-using-stravalib-d5bee7fdde17). These access keys can be fetched by using helper methods provided by the `Client` class. See `auth` for more details. ## Stravalib get started tutorials ::::{grid} 1 1 1 2 :class-container: text-center :gutter: 3 :::{grid-item-card} :link: authenticate-with-strava :link-type: doc ✨ **Authenticate with STRAVA** ✨ ^^^ To begin using stravalib you will need to first authenticate with a Strava application connected to your account or one that you have access to. Learn how to do that here. ::: :::{grid-item-card} :link: activities :link-type: doc ✨ **Work with STRAVA activity data** ✨ ^^^ Once you have authenticated, you can begin to access your data on STRAVA. Here ou will learn how to work with activity data. ::: :::{grid-item-card} :link: athletes :link-type: doc ✨ **Work with STRAVA athlete / social data** ✨ ^^^ The API also gives you access to your athlete account information including friends, followers and more. Learn how to work with that data here. ::: :::: stravalib-1.3.0/docs/get-started/overview.rst000066400000000000000000000105211442076457100212660ustar00rootroot00000000000000.. _overview: Usage Overview ************** The :class:`stravalib.client.Client` class exposes methods that loosely correspond with the REST methods exposed by the Strava API. Retrieving Single Entities ========================== The simplest case are the client methods that return single entities. The entity object types are instances of :mod:`stravalib.model` classes. For example:: client = Client(access_token=JOHNS_ACCESS_TOKEN) athlete = client.get_athlete() # Get John's full athlete record print("Hello, {}. I know your email is {}".format(athlete.firstname, athlete.email)) # "Hello, John. I know your email is john@example.com" Entity Resource States ---------------------- Entities in Strava's API exist at different detail levels, denoted by the numeric `resource_state` attribute (1=metadata, 2=summary, 3=detailed). In general, detail level 3 ("detailed") is only available to the user that is authenticated. Detail level 2 ("summary") information is available to others. For example:: other_athlete = client.get_athlete(123) # Some other Athlete ID other_athlete.firstname # This is accessible # But this is not: # other_athlete.email Retrieving Entity Result Sets ============================= A number of Strava API endpoints return paged results. The stravalib library abstracts over the paging to provide an iterator that will iterate over the entire resultset, fetching 200-page result sets under the hood. This capability is provided by the :class:`stravalib.client.BatchedResultsIterator` class. If you only wish to fetch a few objects, you can specify a limit in the method call or set the limit on the resulting iterator.:: activities = client.get_activities(limit=10) assert len(list(activities)) == 10 # or: activities = client.get_activities() activities.limit = 10 assert len(list(activities)) == 10 Note that setting the limit on the iterator is the only option when you are using the collection attributes on entities.:: activity = client.get_activity(activity_id) comments = activity.comments comments.limit = 1 assert len(list(comments)) == 1 Attribute Types and Units ========================= Many of the attributes in the Strava API are either temporal (or interval) types or quantities that have implicit units associated with them. In both cases these are represented using richer python types than the simple string or numeric values that Strava REST API returns. Date/Time Types --------------- The date+time responses are encoded as python native :class:`datetime.datetime` objects.:: a = client.get_activity(96089609) print(a.start_date) # 2013-11-17 16:00:00+00:00 Date values which have no time component are encoded as python native :class:`datetime.date` objects.:: me = client.get_athlete() print(me.dateofbirth) # 2010-12-26 Interval/duration values are represented using :class:`datetime.timedelta` objects, which allows them to be added to datetime objects, etc.:: a = client.get_activity(96089609) print(a.elapsed_time) # 10:45:00 print(a.elapsed_time.seconds) # 38700 Quantities and Units -------------------- Typically the units for quantity attributes returned by the Strava REST API are not what people would actually want to see (e.g. meters-per-second instead of kilometers-per-hour or miles-per-hour). To facilitate working with these quantities, stravalib makes use of the `pint library `_. You can simply cast the values string to see a representation that includes the units:: activity = client.get_activity(96089609) print(activity.distance) # 22530.80 m Hmmm, meters. Well, here in the US we like to see miles. While you can certainly do this using the units library directly, stravalib provides a preconfigured set of common units to simplify matters.:: from stravalib import unithelper activity = client.get_activity(96089609) print(unithelper.miles(activity.distance)) # 14.00 mi Of course, if you want to do something besides display those values, you'll likely want a number. You can directly access the 'magnitude' attribute of the :class:`pint.Quantity` instance, or just cast to a numeric type (e.g. float).:: activity = client.get_activity(96089609) print(float(activity.distance)) # 22530.8 print(float(unithelper.miles(activity.distance))) # 13.9999900581 stravalib-1.3.0/docs/images/000077500000000000000000000000001442076457100157115ustar00rootroot00000000000000stravalib-1.3.0/docs/images/stravalib_architecture.png000066400000000000000000002313211442076457100231520ustar00rootroot00000000000000‰PNG  IHDR¼¯F¼bKGDÿÿÿ ½§“ IDATxœìÝy˜ÕuÝøÿ×ö}dßTÜ÷ÊDQPK-3µ¬ì{[¿´Û¼S¼µ¼[,5+3—ÒÜ—4EÅ}aSQDYDeÖYÎüþGá3‡›ÇãòùœÏò:ÃuyÍÓÏù¼O¦¤¤$`[òª{j.Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#°s¬~?¦ü•Õ=;D4;AIIÜUÜøíýÇÈW÷4äN4;Áœ—cÒ±þ£{c,|£º§ w¢¨j›büÍQ°."bÓêy}lÙXÝ3#ÑTµ9SâåF”DDd"¦>3Ÿ¯æ‘È•hªÔæ 1îæ(.wk±pSŒúSl®¾™ÈhªÔœ)1û¹OmÉD¼;%Þx¦š`‡ˆF êŨ?Ć?»}ÓÚù‡(*¨Ž™Ø!¢¨"%%ñúÓ1ç…m¼”‰xû…xã…()Ùåc°CD#PE6­‹±‰Â ‰—‹ḇqÝ. €&€*2ut¼>îãES·éÍ'ãgwá@TÑT…•KbôŸ"ж·OvsŒùKlNÝŠ &@U˜ôh,žþù»½;5¦ßùÓPeD#°ÃòWÅÈk£d»·K¬‹Çÿ…¾³à C4;&[Oý-ò—Utÿù“ãÕq–Qø¢ÀŽY³"žûG…n3–ÊÇS·Å†5;s&ªŒhv@II;ž¹Ó“5–h*iÉìxþîMÝZIQ#SGƼi9Ž@UÀV>\cnˆ’¢oÙ5N¼0òêFDdò┋£Y»OU¸!þù±~uއ°ÃD#ðiÅÅñÔßbý‡¹ŸGœ{}²aCãàáQ’Éñ|3ŸŒ™Ïå: ;J4Ÿ¶dN¼täšxѶw|åœÈ”û£nýzQ´ê”ã ‹7Çã×ņµ¹À@y%ñÔßbÕœŽAß‹N½?»½Cïrqî¿x,œÊñXvŒhþ­¤$–΋ÉE&Ç…k¢Ãñ•oG^Ïn¯S7¾ò­hÝ3ÇÓoŽ17FÁæ`ˆFàß²E1þ–È?ÇÃë6Œ¡—DËݶýj‹ÝâøF¦^Ž'_63&?%¹Ö,¹À¿-›ÏÝ‘ë÷"îqt|é±§!:=ºì›ãÉ3%1æÆXóAއ+ÑDDDQa<ù·Øœë—[4hÇ_ ooŸ¶ã˜ïFlõáÕ z÷•˜6ÖÍF€]L4±xV¼òpöûr 8æów;ì”è20×kÅø›#e®‡ ÑDd³1öæX½8ÇÃ3 bØO£qóÏß³e»8ù'us¼Ð{¯Å‹æx,9P땔Ă×búã¹}ò3ûŽ>F¦·)3™8ì´è~Àö}ÜŽlAŒÿklZŸË±äD4@­W\Oßù+r<¼AóòãhШ¢û×o§ý<Và¶ä6½ÿV¼ôOO6ì2¢j½%³c½9.šZ±ÿÐØçË•;jÿÁqài¹\."2%1îϱjYއPI¢j·ÂÍ1þoQ°6ÇÃ[vŒþ_äUò7Šz â¸ó£Iâ?×’™ñÊ(7v ѵÛü1õá-‰8â¬èsp.ÇöÜ/öV¡Ç ·¡(ž¹=VçúyZ*C4@-V¸%þõëX·<ÇÃÛíÃ.Ž:9-…Z¿Q »(ê6ËñÒK^îÍñX*C4@-6í‰xëùͫǞ-rýˆiDtÙ3==ÇßF²ñÔm±~uîW bD#ÔV›7Äãˆ-ù9Þº{|é[‘W'÷2™8þûÑ|÷_ùN¼ø€'v6ѵÕä‘1wbn_—™zq¢Uû¡ÏqØé9~=dI6žº9>zoGg`»D#ÔJ«WÄS·F^®·éºŒCs^ƦœL&¾úhÞ)Ç×½“ŽlN_@ňF¨•f<ó'çxl6â¸Än«f’ÎýâKçDnwÆþ9J r:8Ž‹#¿žcæm­n½~Q4hãáKÞŒQ¨šIØÑµÏŠ…±quD&J¢rÿÔ©öˆ¯ˆF¹~UÆ65oGžÙÊÏSQ’S«rÊÉé‹•€/´v]ãŒ_Æ»¯GÁ抮>šÉD^^´êý¿}¬ÄµŠ ?~æpë KJ¢¤$J²Q\Gž gF¶8Úw‹&Í#¯¿¢d"Š‹cc~|°8^¼?:ôˆM"“·íkED”DID^^äÕ­‚§1jL‰…ª –ªüï%©Û–lq¼=%æ½…[>ï´%‘ÉÄþ'DבÉTús¯Ù’xoVÌEEŸlÝúѵ øJÔ©WÉËÔRî4@­Uù»m•:"“…[¢¸¨¢ßåøîŒè¼gÔ­_é©"ïÍŠ’’¨S e‹??b(Ç3ÀΑÉÄî=£N…ÿõúÕñá¢\.´ne|¸¸SuèS‰©j=Ñì4­;Fûžݹ¸0æ½%•üÒÅâÂxgJ%>4Ûr÷èØ§Ê–~¨D#°ÓÔ­Ý÷©ÄÓƒ+—ÄÊ¥•»Äš•¸ÍX·^ôÜ¿¢— "D#°síÖ-Zu¨Äþ ¦GQaEw.)‰EoDÁ¦Šîߢ}´ë^‰aÀΕɋ¾‡D½†Ýù¼øè½Šî¼jI,ŸW‰Iz Wt"B4;]ëŽÑ¶sEw.ÉÆ‚iQ\›%%±`FE—BÍd¢MçhÓ¥¢cðo¢ØÉêÖîûUâIÂUË*ô˜â†U±lnEϙɋn¢~…oxðo¢ØùÚtªÄ2ªEñÎË%ÛÛ§¸(æL©ÄR«­:Dç~Ý€rD#°óÕ­½¨ÄÍÆ•KãÃí>Ù¸vE¬XPѳթ=ö‹Œ_{r῞À.ѦS´íò9÷Ë[0-ý¼bI¼;# 7WôT-ÛGûÝ€OÀ.‘É‹ÞE½FÝÿýÉeTW.­ÄmÆL^ô9¸Ë·ði¢ØUÚtŽÝºVtç’l¼ûZlýB,|= *|›±m—hÓ©¢;°Ñì*uêFJ.£úÑV˨®_Kß®èòò¢û>n3ìÑìB­;E‡>™ í\Ts_þÔ©ÅE1gbd‹+z¹–¢cßJ @9¢Ø…êÔžûG^…Yµ,>XøÉ×¼_¹ES{`ÑT€ä?£À®Õ¦cìÖ-J*¼Œêüi?ÁX’…¯§—TÝJ«Ý£]÷ <Ñìb™èsPÔ¯ð2ª+Þ%oED¼¿ –¿Sá‹”.ÖÚ —(G4»\ëNÑ®[EwÎdbþ´È_oOŽ¢ÂŠµ[×hmÑT€* €].¯NôÜ?êÔ«èþÖÄÌgcÕÒ Ÿ?/z ŒúM¨¢¨-w½+±ÿŠw+ñ]­:Äî½r €­‰F :Ô©½¬Ä2ª™Š}KGDÔ­=¬Äþl—hªIËöѾg%–Q­èi;DûîU|N€ZL4Õ%½ІMªô”yÑçà¨[¿*Ï P»‰F ú´îúTå Ûu6M¨J¢¨>™¼è±_4¨¢›™LôØÏmF€ª%€jÕ¬MtÝ»jNÕ¦K´ïY5§àßD#P­òêDUðdcÝúÑk‹¦T9ÑT·ÆÍ£û¾‘Ù±_KZuˆvÝ«fÊ@ Ð¥4kû᥋¦Ö©Wuð1ÑÔMZD×þ¹~¸t÷^Ѻc•ÀÇD#P3tÜ#šµÉåÀ¼:Ñs‹¦ì$¢¨7ûær`‡>±[תž€‰F Æè²w4nQ¹C5‹^ìœiˆ@ R¯Atß7ò*óûIûžÑr÷6¢¨QºìM+¼ŒjýF•ŽL*Éd€š¤Q³è¾od*¶Œj—½¢yÛ<@m'€f÷^ºÙØ qôyuvþ@µšhj˜FÍ£çÀ()ÙÞ>™LtÞ+š¶ÚU3Ô^¢¨a2™è²÷çaã–Ñ}Ÿ]5@­&€š§nýè¾od¿¨d2Ñ©o%ÖË`ˆF Fê²W4Kda£fѵEË`LjF FjØ4z ÜvvMZîòj)ÑÔT»÷Šæ»}vcƒÆÑm@ò“«T5ÿÁjªFÍ>»Œj&]ûG£fÕ7@­#€¬óžŸz²±IËè6 ú¦¨D#PƒÕ©=þûè™Ø½O4ñÝŒ»”hj¶.{F‹vMZD÷},š °‹‰F f«ß8úm:E¿#,š °ëeJÊ?\Pe‹cÓºhØ,êÔ­îQjÑ@’§$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#Iu«{ :½4qÒ„I“–.[Ö¬i³Þ½zž:ìäV­ZU÷PÛ–ŸŸÿøc6lpÚðáå·¿>s曳Þ*¿¥yóæ{îÑ·w¯^e[–.[öü‹/rÐå7P¢j¯KG\1vü“;tèÜ©ã{K–Ž{ê©›ÿö÷~͇VºÃ¸'ŸšñÆÿóß?­Þ9K=1nü5×]ûôЧ÷'í÷℉·Ý~ÇÖû?iè/®‘Éd"bÞü×\wýÿ^ösÑPY>ž µÔ¨'ÆŒÿäçwìÈGÿö×›FþóÁ‡î¹»MëV—üüòõ6”îóÚë¯?òØÈê³ÌÈQ£;vØ="F³õ«Üߌ—'ÏxyòÔ‰/=|ß½Ç|ùKýèã£vù˜ÿiD#ÔR“_~¥Q£Fçž}v齸ˆèÓ»×7N?}ý† sÞ~;"6lÜXPPùùù………¥[¶lÙëÖå/~oIÙ©Š‹‹ß|ë­•«V•?ÿú Êâ³T6›ÍÏÏ/((ØþÛ4oþüY³gŸ÷í³ûõí;fÜøl6û™òêä•jP¿~ß>½¯ºü²ˆ˜òò+•ø‰°-¢j©¢â¢M›6½ùÖ§žüÚ©§Ü{Çí½{öŒˆó¾Á#=¶eË–#Ž9¶ô–Ý7Ï9ï7×^÷§›þúÕ!'ÞÿÐC±aãÆŸþÏÏ<æØožsÞWŽ?aè©§=ôÈ¿JOõó+®<úØãÖ­Ë/;ùãOŒ9â˜c§Ï˜±ý·iäè'êÕ«wü AƒôÁ‡N6mûï®°°(//oÓæM¹üh(G4@-5è˜c"â’Ë.ÿýnxiâ¤õë×GD“Æôß»eË–qío~5ø¸A ê×ø¾{tléQ/M˜xÿƒròI' 7þõæ—&Nºð‡<;nÌC÷ÜÕ¾]»_]ó»?ú("N>qHQQÑs/¾PvÅqO>Ùa÷Ý>ðÀí¸µâââÑcÆuÄá-[´|Ü ˆ5fìvÞZ6›½ç²Ùì>ýûWÅ  V³ÔRƒ¾zÌÕW^qëßï¸û¾ûï¾ïþ¼¼¼¾½{Ÿ<ôÄ“O<±yófѵK—V-[F&Ó·Oï²£>ü裑ÿ|°G÷W¯ÞwÎùöYgžmÛ´ùöYßœ:mú» íÖ¶í—Ž:²yófO=óì°¡C#bõêÕ/O}õ»çž“——·ý·u¤É+W­:ä„ˆèØ¡Ã€þ{?ýì³#.ýYÆ Ëö¹æÚë›6i[ ¶¼1ó͵ëÖuíÒù›gž±³~|µ†h€ÚkØÐ¡Ã†]´xñ´é¯½:ýµg_xþw×ÿáÎ{î½ïÎ;¶o±÷ž{–cD\üãEÄâ÷–,x÷ݥ˖5:"¢¤$"êׯ?xРGµ~ƦMš<õìsÅÅÅÆžø¹nmäèÑ7Ú«ßž­\GzèÌ7g=ûü C_¶Ïú ²ÙâÒ?èÀúõí{Ö7ÎlÒ¸ñý€Pke³ÙL&“ÉdºuíÚ­k×S‡Û¼yóŸÿzséÇÒ¨ÛÚîíÛ—ÿãÔiÓoºåÖi¯½Ö´iÓÝ»uëÚuÎܹe¯žtâ‡ù× /½tâàÁãž|jÿûuéܹ"–·fíÚ^šPXX8øäaå·3¶|4^}å½zôÈíGÀvˆF¨Ö¬]{ô±Çuæ—þôâ² 6¼àüï>øð#ï.\TÁóüò׿Éf³ÿzà¾Òï?œ8yòø§Ÿ.{uߺvéòÔ3Ï|ÀÓg̸jÄå<°¼1ãÆ^~é%]:w)ÛxÇ]wO~ùåV®lÛ¦MeÞ7•f!¨Z¶hÑn·Ýžñ¥-å¾#"ÖåçôèÞ­"'™=çíE‹ŸýÍo”†_DÌxcægö9ùÄ!'M~ôñÇ4hpÜW¿ZñËŒ=ºc‡ÝO?í´Ã=¤ìŸÓN–ÍfÇŽ²‚‰F¨¥.þñ–.[vîùßqÂÄeË—/\´h̸ñçÿð¿7nTö±Ï6­[<÷‹kÖ®Ýú ½{õlݺՓO?³tÙ²¥Ë–ýãî{î¹ïþˆX¹jUqñÇ2¤ °ð¶ÛïôÕc7nTñKÍ}gÞì9o<¸ìË$K}éÈ#6l¸ý5T¨¢j©!ƒÿÅ#Þ_±â‹.|òð“¿vúÿ\ñ¿­[·úëÿدoßÒ}Žùò—ºtîôÿý÷%O>ýÌÖg¨W¯ÞO.¼pÑâÅ' ;å„a§<óÜóÞsWï^½.qEÙ÷(vì°ûûÜRP0ü¤¡•:°ÔÈÑ£#bè '|æÒ5:úÈ#æ¼ýöü ªðgÀÖ2%‰eÊ€Ú`KAÁÂE‹–,]Ú¨a£®]:wîÔië}Þ_±b·¶mëÔ©³Í3d³Ù™³fuéÔ¹uëV¥\¸hq×.ëÖýœ¥r>€]I4äã©$‰F’D#ð)oΙ·vÝúꞢ™:cÖÔ³ª{ €j#€Oy`äøÅËÞ¯î)jù‹–Ì_´¤º§¨6¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4T·º¨E&MyùÝ… ¿~ê)õë×/¿}ÖìÙ3^ã¸c¿º[۶Ͻðâ²åËSg8üÐC.Z¼ýztïþúÌ™oÎz«üöæÍ›ï¹GßÞ½zíø»jѰëŒ3vôر'Ÿ8ä3Ñ8ååWþø—›öÚ³ßnmÛ>øÈ#“&OIáWW]ùÄøñÛß¡G÷î/N˜xÛíwlýêð“†þ⊙LfGÞP«ˆF€šå×þ¾¸¨¸ôßoºõÖ»î½ïÞ;nïÕ³gé– ê7èØíïPvªGWÏQXT´hÑâ›n½õ±Q£÷ÛwßS‡¼ëÞðç™F€š¥Aýú7*ý§^½zѰaò-uêÔùÜÊN•W'¯Tƒúõûöé}Õå—EÄ”—_©®·|‰F€Ú¢°°(//oÓæMÕ=ðEâã©»ÚÄ)S7j\~Ë»‹íì‹f³Ù{x ›ÍîÓ¿ÿξðŸD4ìj?»lÄ®¹Ð5×^ß´I“ˆØR°å™o®]·®k—Îß<óŒ]suà?ƒhØÕî¼íÖÆM>u§ñ±ÇGÝûÀƒU~¡õ6d³/™sÐôëÛ÷¬oœÙ¤mêÿ» IDATqãíPžhØÕz÷êÙ¬Y³ò[Ú¶i³3.tõ•WôêÑcgœ¨=,„@’h I4$HÊ”””T÷ @ 2âš¿œ1ìøýzW÷ 5Å#ÇGęίîAª‡;$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h ©nuP£5iܨü·lÙ2kÖ¬>ø ??¿ºFúâjРA«V­öÞ{ïÖ­[W÷,@E‰F€í9iÐѱzõê»îºëÑG8qbQQQuõ…×·oß“N:é¼óÎÛ{ォ{àsøx*Àölܸñª«®êܹóW\ѵk×;ï¼söìÙëÖ­+¡ò6oÞ¼dÉ’±cÇ>|äÈ‘ýû÷6lؼyóªû/ØžLIIIuÏÔ #®ùËÃŽЯwuR#<úè£?ùÉOÖ¬Y3bĈ .¸ Y³fÕ=ÑŽ’’’qãÆýìg?{çw.ºè¢+¯¼²aÆÕ=° î4lCIIÉe—]vÚi§}õ«_;wî%—\¢«V&“9á„^{íµk¯½öæ›o>æ˜c>øàƒê ØÑðY›6múú׿~Ýu×ÝqÇ·ß~{ûöí«{¢ÿXuëÖ½ð 'Ožüá‡rÈ!³fͪÏŸ’ÍfÏ>ûìçž{îé§Ÿ>çœsª{œZ¡_¿~S¦Léܹó AƒÞ{ï½êøÑð)#FŒxüñÇ~øá£Ž:ªºg©EÚ´i3f̘¶mÛž|òÉëׯ¯îq€OˆF€Oüë_ÿúío{Ûm·}å+_©îYjfÍš=þøãË–-ûþ÷¿_ݳŸÛ¸qãE]tî¹çúTjuéÞ½ûwÜqÿý÷?ÿüóÕ= ð1Ñð±k®¹fõêÕ¿úÕ¯ª{ZmÈ!C‡½ð ‹ŠŠª{ B4”Z½zõµ×^{ÅWtèСºg©í®¿þú¹sç>ðÀÕ=!JÝu×]uêÔ¹à‚ ª{¢OŸ>Ç¿å–[ª{ B4”zôÑG‡Þ¬Y³ê„ˆˆo}ë[“&MZ±bEuˆF€ˆÍ›7Oš4iðàÁÕ=;öØcëÔ©c9¨ D#@Ìž=»°°pàÀÕ=kܸñ{ì1sæÌêË—/ˆ.]ºT÷ |¢sçÎ¥/@õ±aƈhÒ¤IuÂ'š6mº~ýúêž%%%‘Édª{>‘ÉdJÿ^€ê%H$‰F’D#Iu«{j»üüüÇŸÓ°aƒÓ†/¿ýõ™3ßœõVù-Í›7ßs¾½{õ*Û²tÙ²ç_|éƒ,¿ñ3^š8i¤IK—-kÖ´Yï^=Ovr«V­ªü]TÄš5kþtÓ_/¿ôguêÔ©i³í¸ù Œêéÿ÷ƒïW÷ T1Ñ@5{bÜøk®»>"öé? OïOÚïÅ o»ýŽ­÷~ÒÐ_\1¢t•Ëyó\sÝõÿ{ÙÏSÑxéˆ+ÆŽ²c‡;u|oÉÒqO=uóßþ~Ãï¯9â°Ãvλٞ?üùÆF•cšmÇõèÞ}ÜSOïµçž_>ú¨êž€ª$¨f#GîØa÷eËß5fÌÅ?þÑg^}äþûzõì…EE‹-¾éÖ[5z¿}÷=uØÉŸ{æQOŒ;þÉ Îÿî¿ÿ½ÒÈ|gÞü]|ñ%?¿üÉ'F5ݵßÈ÷æ¬Y£ÇŽóØ¿jàlU"//ï{çûÛk¯=ôàƒ6lXÝãPe<Ó@uš7þ¬Ù³ÏûöÙýúö3n|6›ýÌyuòJ5¨_¿oŸÞW]~YDLyù•Šœ|ò˯4jÔèܳÏ.ûò½>½{}ãôÓ×oØ0çí·K·•^tíºuoÌ|sÓ¦M[Ÿ§¨¨hÎܹËß›Wù𣦼òÊšµk·?Ì·Üúå£jß®]gÛ°qã†Ëß´iS~~~ù‘òóóËÿ¸Š‹‹ß|ë­•«Vmóêë7lX´xqj¶í¿šzû6nܲeKD¬[—¿ø½%qü c7nÚüÈc#S§à‹ÈFªÓÈÑOÔ«WïøAƒ6lØxÙ:mÚ!´ý ‹òòò6mÞFÚm­¨¸hÓ¦Mo¾õÖÁP¶ñk§ž²ÿ~ûuéÜ©ôûvÄwÎùö¼ùó'LšœÍfÔ¯éÿôk§|òtå½~~É÷ëÛwëIV¯^=iò”ß\ý‹ŠÏvù•W½2õÕ Ï>——ç|ïsÞ~ûáûîíÛ§wDüãž{oºåÖ—žyªIãÆ6nüß_^=iò”ÒÈìÚ¥ó·Ï:ëôÓN-=ç–-[~uÍïbL6›mß®Ý]ðƒëÿø§ïœ{ιß:+õjÇn}"ŽÿÜ·ÿÍsθ߾­[µºû¾û¿vÊðKzqýúõ8ðáG;ëÌ3*òÀ‚;T›âââÑcÆuÄá-[´|Ü ˆ( °”l6{Ïd³Ù}ú÷¯ÈùsLD\rÙå¿ÿà /Mœ´~ýúˆhÒ¸ñ€þ{·lÙ²l·;ï¹·qãÆÞs×}wÞqÐþò׿™öÚk¥/ÝûÀƒ×\wýàAƒî¿ë÷Þq{‡»÷‚¾3o~é«×\wýãÆ_ö³Kžýø¯qÕÒ¥Ë.½üŠmNRzH·.]*>ÛQG¾~Æ9oψüüü¹ï¼S§M+=üåW¦î?p¿&GĽù¥‰“.üáÏŽóÐ=wµo×îW×üîÃ>*Ýó׿ûýãOŒùÉ.üç}÷üäGÿõ‡?߸.?¿äß·(·ùj””TäíGÄK&ÞÿàC§œ|ÒIC†”néÞ½Ûü Þ_±¢"A|!¸Ó@µ™0iòÊU«†9!":vè0 ÿÞO?ûìˆKVþ‰¸k®½¾ô¿-[Þ˜ùæÚuëºvéüÍŠÝÈôÕc®¾òŠ[ÿ~ÇÝ÷Ý÷}÷çååõíÝûä¡'ž|â‰Í›7+Û­}»vW_ù¿ ê׈ßýêê†ò÷ÜuÀÀ7núëm·í·Ï>W_ùq þñÚß}ìq?úèÏ/ùï÷–,ylÔè¯zÊ™_ÿZD |üê5k®½áK–.íÜ©Óg&ygþüˆèܹsÅg;òðÃ#âÕéÓ÷Ú³ßô3êÖ­»÷^{¾:múYgž±¥ `Æë¯_øÃ JOU¯^½ïœóíÒ›{mÛ´ùöYßœ:mú» íÖ¶í{K–ŒýĹg«ô¾â}úäeò.ññÛÙþ«Ûû¥[>ü裑ÿ|°G÷îeï«G·n1ýµC_‘¿#j>Ñ@µ9ztãÆöê·çG+WFć:óÍYÏ>ÿBùÞX¿aC6[\úïx@¿¾}ÏúÆ™¥wØ*bØÐ¡Ã†]´xñ´é¯½:ýµg_xþw×ÿáÎ{î½ïÎ;vkÛ¶tŸ}÷PZŒÑ´iÓ=ûõ[ðî»1oþüuëò÷ì·Ç+¯N+;a—ÎfÎz+"fÏy;›Í8p`ÙKgyFêc™ï̛׬Y³–-ZT|¶öíÚõíÓ{Úk¯•Fà¾úqØawÜuwIIÉ믿±¥ à¨#Ž(=OéêA‹ß[²àÝw—.[6rÔèˆ(½[8çí¹Ùlö°C.»èÑGYöïÛuûo¿ÔÞ{îY¾#¢{·nñÁ¿ïsð@4P=Ö¬]ûÂK Ÿ<¬üöQcÆ–Æ«¯¼¢W¹]"›Íf2™L&Ó­k×n]»ž:|ØæÍ›ÿü×›Koî•­ÔڵܧF#¢c‡Ýßš3;"–.[ÿzläc¥öo];wŽˆeË—GDÏŠÍöî¢E»µmSÙÙŽ:∇ÿõhIIɫӧùè£=øànüËÜyó^~õÕN;öìѽôTS§M¿é–[§½öZÓ¦M{tïÖ­k×9sç–¾TºzMù;Ÿ5ª[·nE^ÝþÛ/µ{ûöŸy§íÛ·‹ˆ‚-[*òcà A4P=ÆŒ_XXxù¥—téüI³Ýq×Ý“_~ù£•+Û¶i³c+bÍÚµG{ÜYgžqéO/.ÛØ°aà Îÿîƒ?òîÂEeW¯YSþÀ•«VuíÜ%"Z·n?ûéÅ_?õ”­ÏߢE‹ˆX²tiéÊ4±~Æ…‹õìÞ£qãFŸÙ¹~½úË–¿_RRRºVjg;òðÃþþ;g¼þÆœ·çþ좋úíÑ·yóf¯N›þòÔ©GqxÙ¿üõo²Ù쿸¯ôË*'Nž<þé§K_jÕªUD¼»pa§ŽK·¼·dIQQQE^ÝþÛOY±âƒˆèÙ3ÇΠ²ÕcäèÑ;ì~úi§~è!eÿœvʰl6;vü“;~þ–-Z´Ûm·ç_|iKAAùíëòó ztïV¶å7f–}wE~~þ´é¯õèÑ="zõì™——÷℉e{nÙ²å[ßùî7ß{öÛ#Ê­L¹ù–o÷Ý¢­‡éÝ«çæÍ›W|ðA¥fÛoŸ}š6mzÛÿ¨_¿þ€þ{çåå|ÀϽøâ›³Þ*ûlêì9o/Z¼øìo~£´#bÆ3ËNXzª)¯L-Ûòì /VðÕí¿ý”ÅKÞ‹ˆ¾½{og¾XD#Õ`î;ófÏy{ÈàÁeßRXêKGÙ°aÃí¯¡ZqÿøGK—-;÷üï¿8aâ²åË.Z4fÜøóø_7*ÿ Ø9sçþæ÷×-~oÉÂE‹.¹ìò¢¢ïwnD´mÓæ§}Ò”)#GÎÏÏqÂÄÿúÉEoÍžsÌ—¾ýúöýòÑG=öø¨§žyöý+îèŸ<6ò˜/©Eóæ[OrÀþ#bá¢OnoVd¶:uê~è!&MÚoŸ}êÕ«‡|Ð+S_­W¯^ÙuôîÕ³uëVO>ýÌÒeË–.[ö»ï¹ç¾û#båªUÅÅÅý÷ÚëˆÃ{è‘]{Ãߘùæ?î¹÷Ö¿ý½l†í¿ºý·ŸòÞ’¥ 6Üz) ¾¸|<€j0rôèˆz ŸÙÞ¨Q££<âɧŸ™¿`ÁŽ_eÈàã·üé/7]xÑ'ŸÐï_ÿâªòߦ8lèÐ×ßxãÁ‡Žˆ¦MšübÄ奫¹DÄO.ü¯‚‚‚+~qõquDtëÚõ¿»f¯=û•¾úë_\uÙ•Wýô~^úÇ/}ÔU—_¾ÍI8°N: ->ôàƒ+5ÛQGþäÓÏ”%â!x@ƒ J·Ô«Wï'^øç›þz°S"b߼箟\ré¥#®hÕªå¡üÛÿûåï®»þÁ‡¹û¾û»uízíosÁ~\¶’Ð6_-{¬qûo›.ZÔ§w¯Òï–à?C¦äßßÅÿ‘¶,\´hÉÒ¥6êÚ¥ógn‚ísÐ!çŸwîÿß—,]úÁöíÓ»iÓ¦Ÿ9ÃÊU«æ¾3¯Q£†öÞ»N:ŸyuÍš5‹ß[Ò¡Ãîe˱nÓÏ.±n}þÍúcÅg«¸l6;sÖ¬.:—>ˆ˜Íf.ZܵKç²üËf³›7oiܸÑû+V7ôä;ÿvëÀ}÷-xùWüÊà~çìýzWäí—·yóæcN8ñâÿèk§ ÏíT£‡zèŒ3Îð{Qrúé§GÄC=T݃@mçN#ÿáÔ¯¿GŸ>{ôé³ýÝ:wê”j¶6­[—ÿ^ŠÏhÙ²eË–-?wŒ|ï»§Ÿuö¢Å‹»uíZÙÙ>W^^Þ¾”ÿcéÚªkÖ®=óìs¾vÊðóÏ;·tyžQOŒÉd2}ûôÙΫÍ[´*òí¿ýòbL«–-‡ =qß5Šh€]¡WçœõÍ[ÿ~û¯~qÕ.»hË-öê×ï¦[o›>ãõ}ôŸ;oÞÓÏ>wñTúñÔÔ«ï,]ó¹gÞZqqñíwÞ5â~Vúø%ÿ1eÄ59cØñeÏ4Öži¬<Ó5„;$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h I4$H$‰F’D#I¢€$Ñ@’h¨íFû℉Õ=PC‰F€ÿŸ½û¯£:óþ¾çÌܦ^,÷ÞmlÜ€0¦„ ´ 8$$lv³ÙͲ$!Ò! Énø¥Z(B5n@Œ+ÅÆƒ»-Y¶%]]Ý{gæœ÷÷Ç•eIö½–lcÙÒ÷óÌó «9sæÌð<’¾> àØõò«³îþù½Ÿô]þtÿÏ<ûÜ'}8N!4P$!¢t:ÝÑ ií÷Þ{jæ³ÝŠŽ‘L&£ÑhG·ˆÊÊʈ¨ººúð« ‚ÀZKDµuuËW¼ŸL&›ŸM44d¢i]]|Óæ-M߯©©Y´dÉ®ö<¯yaÏó‰(û¾Ÿ»p“ÕÕ /®©­ÝÿTî Ûû8‰††úúúæ…­µñx¼í•çV]]]ZZzDª€Ãátt:Þˆ#ˆhÅŠ½{÷>̪&L>í 7\¿fíÚ7¼e­ ‡B·ý÷7.¿ô’ÌÙkn˜1~܉¥%%=òèå—^rÛ7þ+íy÷üüÞ'Ÿ™™)Э¼üŽosê”)D4ãæ¯|¸z5vö§¿óÍÛ®üÜe9 Q2™üáÝ÷<ÿâKD¤”:qì˜oÝúß#† #¢ÜÚãÜóó{Ÿ{áÅY/<ß­¼ýìs—L¿è³]TXX0lèÿýÙO‰è÷ú õëÛ·¤¸˜˜‡ RTX˜»ðæ-[f>ÿË/»ôê+.ïѽû´óÏ›qý¿lÚ¼yKeeî ùq&MœPÑ­Û¬Ù³3ÅöìÙ³tÙÛÓÎ?O©#ðæ“O>Ù¿ÿ±cÇ~Up˜ˆˆ¾ð…/¬]»ö¥—^:üªN;& e¾ÎÏÏ9bĺõë›ÎŽ9rà€™¯ß_µÊZ{ÒĉMgc±èÁƒ×¨0wá>\m­4~|ÓÙk¯¾ê… úôîÝ®»´ýq”RÓÎ?oÙ;ïîÞ½‡ˆ^›;ÏZ{áùç·¥ÎÜî¿ÿþ3f~UpøˆˆF=}úôÛn»-‚ìª_ß¾Í?öêÙ£>±oÁ˜Ý»7}½uë6":aô¨æå+º•×ÔÔì_mîÂ[·m#¢A¶÷ÂÃyœÌÕ׿Î%¢Y³ç 2xØÐ!m©3·»ï¾Ûó¼[n¹åð«€Ã‡ÐÐè—¿üåš5k~÷»ßf={Z†±]»w÷ëÓ÷€%KJJˆhý†Í¿¹êƒ{öìÑÞÂEEED´¥²²éT}"ñþªU ÉvÝ¥]3lè¡CÏš=§¦¦fñÒ¥]pA[*ÌmÓ¦M÷Þ{ïwÞYQQqøµÀáChh4xðà¯ýëßýîw?üðÃégùò™m*ˆ(/{û°ä°!CˆhÑ’%Mߩܺµz×®qšË—»ðÈÉhɲeMgý»ß_7ã&?ðÛu—ö>ÎE\°dÙ²'g>KD^p¸cS}ߟ1cF¿~ýþíßþí0«€#¡`Ÿ;ï¼säȑӦMÛ¹sç!WòáGýäg÷nÚ¼eÃÆ·~ûv?¾4ãÆ–5rÄi“'?=óÙÙóæ%“Éõ6|ç®ï‡C¡®»6S ¬´Ôó¼¹ó_¯©­Í]xİagž1eæsÏÏš=gûŽ>ñ÷§f>{ö™S‹ z—æ~ô±‹/¿òý•+Ûø8œ÷ùýÿô©“OªèÖí_ZÆ×¾öµÅ‹?öØc®ëfUp¤`ŸF€}"‘ÈÌ™3O9å”Ë.»ì…^(,,<„J>{ÑEï-_þø“OQ~^Þ÷¾sû€þý³þñ÷îüÖwýç­·e>Vtëö§ßý¦)}}æÔç^xá?þûÖÌ>¹ ÿø{w}ûλ¾ñÍoe>žyÆ”»n¿½-winOM͆Sét§G÷î'Mœ°xé²Ï]rI{_T+?üáÿð‡?Ìœ9óÄO<̪àâ#²®4tß¹ç×W}ö¼1#ŽÀr&ǯ•+Wž{î¹eeeÏ?ÿü€½+¶ÑØ“Nù⌿ö¯·l©¬¬ªÚ9lèüüüƒ^µuÛ¶õ6ôêÙ³¿~ûoY±}ÇŽnååZë¶®©©Ù´yKÏž=º•—·ë.‡ü8?øÉÝsæÍŸõÂóŽsˆ¾ïõ«_ýË_þrß}÷aý€c zZ=zô¢E‹.¾øâSN9åþûïŸ6mÚ!TÒ§wï>½{·±p¯ž={õì™ílóWZ¸¸¸¸¸¸øî’[¶ÇI44¼ôê¬k®ºòㆠnºé¦Å‹?óÌ3Ó§O?´Jà“ƒ9зoß7Þxãœsιð §OŸþñÇwt‹ŽEÿñß·^7ã&"ú—k>—744ÜqÇ£FÚºuë›o¾‰ÄplBh8°üüüGydîܹ7n=zôW\ñÜsÏ544ä¾êÒ‹§5òè´ð(Èý8Zé Ï?ïù§þ^Ԟɟ"²dÉ’Ûn»mÀ€¿úÕ¯~øÃ._¾óŽY˜Ó-`Nãþ‚ xì±Ç~ÿûß/X°@k=|øðÞ½{Ú9]\*•Ú¹sçªU«êêêú÷ï?cÆŒ[n¹û1ã „ÆvìØ1oÞ¼÷Þ{oÇŽñx¼£›sp‘¼Â’î}¶­[ÕÑ i‰DJJJF5yòä±mÛ%:Âh«îÝ»_uÕUW]uUG7¤­V|¸æñg_ù¿'žèè†Àq s +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +§£p”¤ÓÞÎÝ5µñútÚ ÓÑÍ9¦­Ù°9™Lwt+à•¯¨û IDAT¨ÜQED‹ßYÙÑ 92G‡Ã¡¢‚ün¥Åáp¨£›ÐU 4@''"[¶U­ßT¹»¦Ž‰"±X(R¿sñ,%tt+àð,užÿ›ÖKy{êÖ¬Û$D¥Å…ûõîÓ³‚™;º]~e@gV½»æÝU%êJÊˇ]X\¬´îèFëfÍžß³wŸ!CwtCà`w튫†ŽÕÑ 9’¬1u55Õ;v¼½üƒÕë6Ž5¬¼´¸£Й!4@çóΊÕ[¶í()+3it$íèÀ‘¡´..++.+K5$7­[óÆ¢wúôì>~Ìpÿ$ðÉ@h€N(™J/X¶<™ô†S\ZÚÑÍ€OD$v˜šÝ»×}¸zþ·O86 wt£:!¬ž M]}bÞ‚¥‘ÑÆ#1tzÅ¥¥£'ŒŒÌ[°´®>ÑÑÍè„ SI{Þ‚¥ËÝHtÔøqáH¤£›GC85~œ‰.Xº<íy݀Ρ:cíÂeï[¡¡£Gëìë£þü'?8êäG¼?wm?ðç›®»êH·>Z;ÃNMÌo-]n öÔ8’ óøðã u‰Äð±c\×ÍVÆK§ÌŸ§µž÷Ú¬Oº=ßÿÎmßú¯¯}Òw€ Çq‡žpB¼!ùášÝ€N¡:‰DCr͆Í}úŒFc9нõæÉTòªënظqýúµkŽZóà(ˆFc}úX³as}¢¡£ÛÐy`õTè$–°&Vôê™»ØìY/3ö’Ï]ñØCÌŸ=kàà!ÍÏ>ùØßÞ|}M>mŠXÛô}Ï=ýä’E 6oÚ4bôèI'O>oÚED”hHÜóý;®ºæú… Þ\øÏ7µÖLÿìg?wýïOüþòåÖšÛ¾þÕ;ò³X4¶dá[O?ñè–Í›zñeWN˜tR¦ò»¾}ë}¶ºªêÙ§ÿþƒŸþ‚ˆ_}ñù7æÍñ±½ªºï AÌœ£XmÍže‹uι…EÅcÆŸ7çµægÿø›ûþü»_4xÜ„IO>ú·çž~²éÔ_þðÛþüû={ÝxóW"áè/ïùÑìW^""‹ßZð£ï}gýÚ5—|îŠÁC†þöW¿xêñGˆhôØKJJ ‹ŠNúÔ©Žv^{õå;¾ù Ï÷®øüuápä»ÿóŸoÌ“©|åòå>ü×_ÿß½Ý{hGë_[¶tñ×\7õìO/[ºèÇw}÷È¿/€Î‹™û´½ª+©)èi€Î`ã–m‘hô lÌŸ3[DNŸz61õìû~ù³W¾?bô D´«zç̧ž¸á‹_¾úºˆèÊÏ_{ÓµW‰HæÂ÷W¼wÅÕ×^7ã‹Dtö§Ï{ù»ï/ïœó.Èœ-++ÿþ=÷*¥.¼ä²À<øÀÅ—^~îù¾õæëɆäåW_›N¥þòÛÿwÆÙç|ëŽÑÅ—]þÃ;¾õðžrÖÙí_¿öO=Þ«OŸõk×lݲù¦¯|õ´)S‰h¤“zàO¾ç¹¡Ð'ñÞ:¥âÒÒH4ºi˶F 9xi8ô4@g°­jWiyùA‹½öêËã'N*.)!¢ÉgLUJ͛ӸÎ’E ML›þÙÌÇX^þégžÕtáÿýöO×Íø¢ïy[6o|}Î캺:?ð›Îž6õ,¥¥N9óœD}|Û¶­Íï»úÃvïÞ5eê9‰†Dæ˜pÒ§6nX·{÷®LɧžÑ«O"*)-ÓŽóÀ~÷ÆÜ9 ‰ú'Lüù¯~‹ÄÐ^%åå[«vut+: ô4ÀqÏóýD¢¡Ï ƒt)lݲù£VF¢Ñk.›ÞôÍ×ç̾ù«ÿ¡”Ú¾mk8),*n:UV¶/…®ùhõß{øÍys1}û pœ¿@{õê½ïëÞ½‰¨fÏž~ýì»uå"úáßjÕ¤úx¼´´Œˆú ”ùNqIÉ]?þéƒþÃîº])uÂØq—_}íÉ“OmË{€&…Å%Û6oö|?”}-eh#„F8îÅëˆ(–—kÑT"šýêËŽëÞøÅ¯4Í{ܸaÝ‹ÏÍ\þîÛã&LŠF£éTjwuuSåúuk3_Xk¿ÿo—–ýä÷ ><}í+_dÚ7yÒó÷õ:ÖÇãDÔ­¢¢ù­c±ýà§¿0pPóïgc+'2ù¤S&oÞ´aéÂ…¯¼ø»¾}ëoþüà€AƒÛô.€ˆöþ@¨¯o(-)êè¶÷0<Ž{žçQ޽3æ¼öêÄ“N¹äò+?û¹+2ÇM_þWí8ófÏ"¢~ýÑŠ÷Þm*¿âÝw2_¬\ñ^ÕŽí7}ù_ÇŽ‹ÆLlÞ¸NHšJ®ýèÃ}W½÷®ãºÝ{´Xĵo¿þD´qýºnÝ3ÇÊåËû«_°jý‹xù»ïÜõí[­µ}û ¸ôÊ«¿w÷Ï­µk>ZÝþ·Ð¥e~ ¤›ýƒ2„F8îYk‰hÿÖÜ+Wl«ÜrÖ9Ÿiþͼü‚ñ&½9nàû'O>µwß~>üÀÖ-[ßêñGjkk2Åúè†BK.ð=¯ró¦ßõt*N§šêy~æÓo½ù†çyË–,~êñG/ùÜ•™)Žùù[+7o«ÜÒà ‰'üÂs37mÜN¥^|næoïûeqI©Ú¯ÍC†}{é’ûÿðÛ]Õ;}Ï›;ëef4tèyQ]Gæ‚5ö %à 0<º„Ù¯¾ŽD>uÚé­¾ú™g/]¼pÙ’E§œzúw¿ÿãïÝ~Û®½B)5hÈÐkoøÂìY¯QaQñÕ×ÝðÔ£{æï)­¯üüu'œ8þ¿ùÕŸ~sß•×]OD\tñ½wÿ Q_ï†B“OŸrýM7g*?ãìO/\ðÏ×\ñ÷¼òo~÷îÜqóõŸ×Z‹Èg.¸ðK·|mÿvÆòò¯¹~ƃþÃS?⸮µö†/}eÐ`„Fè0Ü´˜8Àqªr[ÕâwWž2uêáWe‚`íÚ£ÑhŸ¾ý[mù˜N¥6n\?pààÌZ¦»««Ã±¨ ‚+§ŸÿïÿäÔ)g¬[óq¯¾}cÑX« ëâu%%{l«ÜRµcG¯>}ºUtÏьݻª7oÚÄÌýúȬõz4ýæ¾ß~æüÏ ŠY”Áš×¾úò«ÿúï·ttC:À¢ùóO7ºwÏŠƒ€œÐÓ°vœaÃGðT8i~*³^N]]mæ£RjȰᬰ)1QÏÞ}zöîsÐf”–•—–|€£s +„F€C‹Æn¿ëG#Fêè†|R0<àÐ9®;嬳;º‡k×®]­™¬«­ÛYµ³écqq±Âé]B#@W·xá’õëÖ7ÿη.|«ñkGë¿xãÑo#0< «:t`AuVÜ·¿P(t´ÛÇ „F€®nà Úuˆøç¬ q€Ua ë@hèê´£ ¤Ôþ*Юӯߣß$8v 4 6T¬mõM¥Ô¡C+ ti@}úõ …í¾)Ö6´CÚÇ„F ¥ÔaCt˪¡p¸Wï^Õ$8F 4ÑÐaCL³ªZëa#†p¢#t)øMDD={õÊË‹5}4Æ 6¤ÛÇ„F "b¢aÇ7u-æççUôèѱM€cB#4:|ˆµ–ˆ´VÃGŒ8оÐå 4@£òòò¢B"2ÆÁØT "„FhnÄÈDTRR\VVÚÑm€cB#ì3tè"6rxG7ްOQqQEî™è@ÐÊé§ŸZXXØÑ­€cB#´Ð£'vÚ€} +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈ ¡²Bh€¬ +„FÈÊéè Љˆ¬ª˜„ˆ…ˆ‰˜D‘3g>*%DFH˜,û¤5‰31‘ÈÞÃR d„„H„ˆÈóȘ½7J2ek)çü¨„¸Ù•Z3iMJ‘fЍ}‰IlãõÌd„Hežˆ4 ±YkEç~uL¤Ø‘–Ö:°#øäXJ&ç¼úòü×^Yñî;ׯ««­±Övt£ˆˆÜP(/¿ðGßü·ŽnÈÁ)¥ ‹Šû4fÜø©Ÿ>ïìÏœ‰F;ºQ@„ÐpdonTLNcHb/ @$ž¢m»mƒG–Ôž=A"×509¡PÔä¥m:¤Ó¾g£C{C§Ó&„ˆD„ˆê”c‰yßíöQ$Zl³ïKóbŽ1ÍãÖ¬´rå¸ÊaŠš„V¬]rÖ³bfË,Ä’_éÖ½Ð!ëJзG¸ Ä1­¢!&«Ø'i̾y-mMŒI¼®ö¾ŸÝýð_þX¯7éäSNŸrõõ3ŠKJ•Âx®ö±ÖÖìÙ½~Ýše‹>ú׿ä^÷…/ýû­ß,(,êè¦tuÐ%Á­L.b"m)¨M»Í®=‰Õ Õ5´m§SSçî®aÏhÖNM^Z«(QŒ„™]&EÂ"DB"iâ$³b&fÖ™ (B"„‰8Ók({S)±(QÊêL|ËnÞ£%ŽÛbê scW&‘°òlo"²Ä”ÉŠÌ™¯ÉOûšFkíã=p÷·‹È-ÿùßW_?£[E÷ŽnT'±³jÇcÞÿÇûþ÷ñ‡øæ÷~tտ܈ÐXø³àøQ¹­jñ»+O™:õ(ÜKˆRUíÖoô7orVWó–¸GÚñ­õ¬µŠD)QJ”ˆˆöbfÊ %kEˆ3]ƒ–IJ%!+–ˆ”RJ5þR¡² PeQʸ̔é•ÙÛc)¾VVíëëÚÛAÈBnJeFÒ Y¥„Y¥ˆH “ͤW±"Ƙ 3ÆÖÑÊuÝ~ººW¼¢‚ôÈAáÁ½c…nž8,å´Q¾¨.ØÁXW[óåë®Zðú¼n¾å·ßYT\ÒÑ-ê„jköÜû£ïýõ¿=õŒ3ÿðã…EÅíº|Ñüù'Ý»gÅ'Ô<€®=§X‚€ëâöý5´|ƒZS%Û’V¾CLÄlIXû–´rI”½‰Q› uQ&¬‘dÆy‘8djÊM(™“i¶foh䦀Ø8‹R©¦òen6>•ȱDfßGÛlH)“¸l83å‘…ÈŠ±D6Ó›I®Í$b…H‘£ˆÅJg•*þhè´÷ö¶_irxo3q¸Û·;—a'÷£iÓµþ¢Ø¸níõŸ»8‘¨ÿÇü·ÆŒ›ÐÑÍé´ŠŠK¾ÿ³ÿ½âÚëg\yéô3O{ð©çúÜÑ芺Öx€C`ÈVVy+V4,_­Öíˆ&t4¡Ãé°Ü êù®&V¤ÂÖ•4)ÎŒ_e Ø4õ2“R,d…¬(EBD™Ž¾¦m˜Iˆ”QMaOHHˆ› ±Íç16ïãÓF5_§ù$D!òµw‚d&Ö‘Ê,Ù£Œ£Œ&±b…Éj²Š±žëZv1yìtßQÇ5µ´f“?{ÙŽ!ƒÌĉe'Ï/Tâ™,£ÑÆuk§ŸuZŸ~ýŸxqV÷ž½:º9ߘq^˜ÿÖŒ+/~ÖiÏÏý'r#Àчá©pÜû䆧ŠÐîZóÔ*^úöödCØ÷b–C¢”°6ÂVYWY§1%6õáQãÄGÑ{g²‰ˆ¥ÌФDF±a½ïN¼/‘ö©y,l9P›«ßdµ6PUû­¨º· ‹§[­­“·j‰(ìk×h!&a²Y«,‘%«}’Ù0[%,Ld$`NDóxbŸØe'†RŽ+$¶í«§§êjk¦ŸyZ^~þ“/ωååutsº†DâòóÏNÔ×??ïŸm§Šá©G z@„|C«×Äg-¨œ]=B¤‡v|óÉ$•¸œÉŠl,© ±S0¸,‘ÍLGd×4&9nJ~¼7F ÛÙªÕÝÔaÈ-3cÓÅf-Ý÷_[{ÀÈ&ÄDŽ˜ž""RiÙ¦³e²¤-¹ÒØY™Gʰò™|&kK¢´DêãzÙû²gC݉cä3S‹ºˆ"›ô;í_ÖÚ›¯½2‘¨âÅYHŒGY,/ïþ'ž¹pê䛯½ò‘ç^ƺ8GS§ý±pÈDhW\f-ª}ãÝ=I)èz8⇕Љ%K*°¤…BÊf¶m"Kl„ s d‰­¤¨„(Ó Ø¸à*qcÆs¬ ¶Ù [0Ôb¦b‹³œYjµÙ•Ídf7Ö—f¦S’%E{×8•ÆM%Ï(_±ÃÖ×–]Û8†¶6š§D) iVÆjhòÙ$µ2FGW§ò6/«^[Usþi=Ç‹†ñ‚Î9éñ‡xëùÿ˜ÿF¥vˆî={ÝÿÄ3MüøC|þ†/ttsº„F€˜iåšš¿Ï«úxWAB÷³‡ƒZߊ'&¤8¤I¹Ä†Ès-ka"¶ÄD.‰ÛX‘xÊ'"ʬžºw ÕÌ0TË-úIZ…½@·Ø¾å\Åjqa3¡ PYòš0YeˆLcÚ;ŽT‘cÂB"¶D"YKưIi2 RBšÄq¬¸JG2¢$¡Lƒ+Òmù†ä–Û¦Œ‹\|n÷²B6&ð’vîÃq,‹Çë~ú½ïÎøÊW±òM3nÂ_þ×»ï¼ýÂK.kïbªpÈ:Ïr€Ã×ÈÜUÁ¯_t>ØÞ'T(8~ û1mc®D\ÑÚŠ¶F[« iËDd˜ ‹°²ÂVØ !#dËŽeÇŠc­c¶¶¶¾¶¾²FšÉÌ\ÜwØ 5;„ÉovÍ@/Ëá³1$†Ä°Ý{ÃÆp`8°äIæ_( ÆY–šÉar q@dÈöŒJY6œX6a1Ežç&MyU~¯W–¹<³gG »œ¯wi>ЀØãÓ}?ý‰1æ?¿õÝŽnHW÷_ß¾CDþßÏïéè†t!¼€ç,‰?ôʪ„“ÖQb­­rM„Éar˜4“bÊì´(LÄinoÔciupã&ÔìªÆƒp´‘ˆØ¦ƒ¨ÅaI,g?šÝ×ËÖ²±l2é—8Ó%Ê™# 2$†¬!#œ|k‰-‘ÕbµµÖlZ‡\¼ìÃäCOo^Si­*Œ8Á'ø?ì(J%“ÿå_ú÷¯c?ÆWT\ò¥ÿúßîÿS*™ìè¶tDD ÿýŪ—ç§j닌öÅÝCN[­¬wÒ*Þ¶ÖÉ(1l|m=E Ñ5R´è£àß°²Ê iÏU!7ÎyõåúxÝÕ×Ïèè†ÑÕ×ψ×ÕÎõJG7 «@h zŸ_Y´ûõõµ~‘ÑV\Å"JÌ~‹ÔM›[ð8ȵÍǵÍ;P[ —mì©$ O©z«•Ë7GþòlÍ–¤¹3lÁ1ÿµWÆM:¹[E÷Žnu«è>nÒÉóf½ÜÑ è* «SLsÞ5//övú¡:J©˜#ã ˜M>³§tükn=x´Ùq|ÉSÙ*hmH[_Iš) ÇñU¾îµjKôé«wTKÄÅÇ÷rªï½½lâ)ŸêèVÀ>N>åý÷ÞíèVtX=º4!š½x׳scµ~…D“ÄÖ7†­£ŒbV¤â–Sb£m­­e¦ÊÑg(D¶YaæEÛÞ…ØúŽÒ®é‘YÓbÝüƒˆ ö8UÖ IHlÚÑiKV2›y0!ciÁ;é æê«Êºå§Ó~Ø·‹©nÞ¸ášA7ut+`ŸAƒ‡>ù·‡:º]Åñú³àð Óºªàù×qß X[‰’D•h-¤˜˜¬+ÔÖÄØ¾[Ó~ ã´yxêkCv­J*Ùw°´üH¬,)!ÇJÈš BÖsÄS”fòj¥dÉGvöâíõÃÇó(Õúx6x8¦Çëj;º]z ëªKاgoÙÚPdW ‘Õ,šY˜=fCa¶ÑCOÚŽøwÔÕ!O‰ä–]™–D´0‰f%dIŒcUˆq:ˆ¼öÆú=‹O:"½ "­õÁËÁÑ¢µ‚ΰÆÀq=ÐEKÿ\ZÿîGÒÊ·dYÇZ××zޤ¥‰EÈ1tèQá€k´w‡cœÑ6í¾¶b—Äe ±8,šD5T§ ¸ª®çìY‰=»;ÍCt-ÐE­Ý⿵Ü#§Âh&²JŒbŸÙg ˆXH“h!&–ÜÙ/g,lsll}“rŒ*"iµ=FË£åZŸmY²ù-³7NHŒ2F‘eeYÒ–Ø’Ò$ÊWuû¢KÖlV¯¿Qœ¯†ÐEax*tEIK]¢VÔ–E% ‰ 2D™‰œfËÒøDd[ŽÍ1ª³uØ"sàr™[µ½Ú¶@%"Öf.«¤Å@×VMm¾ÜSîÍDZ,„]‚bKÄVÔæéU•œV.krEÅŽ=À6Y1·î–ÌVÒ¶ZB§Å-ZL¤lUIëËÚðrm{=‰¦m®Ç(&q4bP(M~M²`Õšä€^î!î} Gz K`¶Ž²,´b·%n½P hË®’öý6lO ·*Žø1k”ÃÖ±AZ{V(ǤÂVªy¦r$E†ÂŽeò˜Åñ¥ÂÔÀäÖ¥Dž[_ QeBJkåb’~2Ýà”$ˆ<qœCÌ[qmƒpƒ“rH “Š(â@t­¨@lŒ$Âä7ojóäÕ*íò$Æý.lù¯Ì’ãS y^  ¥8¨À²QŠtg­Y·ÓÛšŽ”»mlt$„Fè˜(0êíuA\Ñ H$šbRì9Æz®• EôÙo©›¬b,¬D¶¬–"L!e-YÃ)//ä.¶ý{;ƒzF‡÷)íQ-Œ²v) JøfWµÙ²Ýù`[íû[vm©ß+uU1)Ÿ8¥|GD1û’Y,G\"²–Œ“8Š÷…F>Œ•_Ûѣغ`Ë’½žh`„´¯]CšÈ²£•‰‘IlØìFh8N 4@—À¤Ò)^»1nI ³µ–˜©qÎÕ[–3XYr•U¥˜<_Õ+JëÔÈ<9ixù…'ÄJKU^ž ¦€­CĤ=«ó Ô˜>Sü‚­uÑ7WÔ¾¹"QYGÆ*LjJjã:FyÄi¢$±#B$Q²!¶an¹©d»–9å£>Tˆˆ…I˜XD“±$U;wÇã%”ïoOtEÐ%0Q˜x‚µÃ$™õL3™Š™„[l‡Ñj¬jÖ`cˆEyZXû%†¬­Í {§^:®lLÏX$ê‘N“2^ ý@³bcI‘Ë*ßr*®íÖ=B±aS»Þðâ²ú·–šd¢ öµ¤YY⤰ff‰°EdÛ>µù©£Ÿ‰2ûcZbK¢ˆ$ðÇzVÙº„_½ÛPOÐpìCh€.ÁZªÚlߙ𢅥‹0‘Î1k1wÚ œ8YåyŽÕÑ”Û-O.˜RzɧÊû¸ š¶ïWÔ48ë6Õ¯ü¨nËö]5q?\‡Š £å=ü1Ã"ã{—÷ ›BÕpFowd¯î/ôØù̬Ê=ñÞ¢µPX()ʧIIg IDAT´²Ž"böˆló5Ú5kñÐs#çÞU1{je!²DFYq™Y´o9… ·V‘Œ<&V€Ü K,m©_B:UÖŠo„…؈U†…¥EOc«{ûõíícØ*7BìJ2Ñ'?yÍ´§¹F¬³»><ó½ËÞÙ±n'ÒEV…D‘VŠ Ó6|méö!å5gPqÚˆâÞe¶\Õ]zJI·‚üŸÙ²¾†É*¢°vM` ±',"QšÈŠDí%äXíf¿iœ­»3›}Ý:{r®á²"l‰ +ÑÄJÈW.'=Þ¾Ã÷|EŽ>„€£ ¡ºÖ”ð•) ÈñX,“°X!!nÝÛØ6š ý€•­+Õ?¥dê ¡"Nù6üÁVzzÖ¶—W±eŠËD‡ñÔºÚÓ*,±”益¥[ÔÛk ?N_yvÑøn¾±g éóÔ¯^ÞU“ˆ*.L{†µgÙ¶DBË'³?HÛIÛÇ” ‹°!2,š¬C¤DQl$Ô £‰óð£ºciGuÂr^`)ÌŠ3½‹,,ÌÂLöÐn*ë„ÈæëçM.ºä¼‚“06ôÖû ¼T¹j[¾ê£2ÆSÏÊs]²J[°DTÈ•ütHùáâÙv¯}|ó5S{L›T\èÖŸ71¶6ˆ¼ðÚöx}DI+J ‰ q‹Ñ©íÓÆÙ<ߞ­.ËÄ]Eâb߈²­®‘xCPÆŸ"Ç:ü¤€.Á7´½:n¤ÀZbRL™0sx ±8Š´©Ö=8or¡–t½D×l þüüšÍñ^A8ÏM×Åòêzõñô-ܰ®zÇÎP}ªÔ:Qq)°†%©É §œ»#Þíá¶–»gަ°±N ­Ûà,Y’pT1i!—L˜È’ö­µ¹S͹jN[Ccë:Y83ÑÓ‡Û<Þš6åO̵ËÖ”{Ú­#ÇJí×ϦORy¡uÖ/ì‘_2æ\ž>Ú<úÚǯ®nØ“_äåì)‹¥òȱ{ØK9~ÃŽÂ?>m¾}s¸·éÑ[­RûìZ§6-Héü†£­çì±,LûiöpÓÙ'U«`ܼk±Õ3·ê“T-â]«Y•Í{EZ”TlI˜$D®£@”DMÚ„tJ©´‡ŽF€ãêè ñ8%“Z„DˆY17ïZÌÕɖ휈Q~(5ndyq^¾¥¼u•©·WV¦Å·2zìˆpD…$è·»¾´ŽTÊ¡î½ nøÜ¸¯~näĨӣª  %L*ßíÑ‘Z'¬7nK­X¥#1gÂøÁyQf"&ËLÄ96Ì´Vš9¤WÕn­î(Ö:ߦ=›LG§…p8 Khh°".³"!km&Ýevl=J²UÖiyŽöå1‘¨T±ãùË>ØSÝ`8æ’ ” •Ee½dÚyéÍʼU½)ò™Ê#öü±%w\1ì_&DúºÛl°;퇈ÊÙ[V»‚øÜ÷w¥Å²F Ë+.°Nch”ƞ‡ åP6ß URlqë˾9¬ ¥pt 4@—`ƒ¢"VLjo†9p(Ê|övå5•éWšÚ“ØH2­®¬Is,M~@ie­–hˆòÙ5⦷ìñxfÍÿýuùòu‰€UXû#zÚ¦çýÇMeŸ.vw¸i£%äsºA»«¶»{ â ´˜úõŒHÚg¶FYa›Fš+4Š•fÇ!g¿vÉsŸÒV0Mà8€ÖÐ%ˆ%+VŽÖ 2ÝŒªqOˆì—¶ø3‹ØÌ(Ñ~Ýt”|Åz×Z»C<6l” +ÇÓ+Jزr|SºbMú7­ûÌ)%S&wïÑÍqòÓcG¦+zõ¸pÏs vV¦óSì‡UÞ–fWm}ßââ uË,ÄJ2s+³`jñ,¹Ö@=È Ëzâ`Ý›ràUX›7…pŽèi€.ÁZñ¼€„iêö²""d„¬ØZ]»oœª‘h¥˜YkÍÌÝ uˆSÌo ú ÏR(°¤˜5‰çSƒ›6Ö6æ!× N·uýíÿä¡5s×Öï$VÚTì_ñ©>§ ªùa¢Ñ)kýºDŠEE][QâÆœˆˆX6†Eh¿á²ûaÞw´êidÕÖCÈ6?šËsOefafk-‰È~¯ŽAèi€®ƒEho?]ã¬Ä½sÛÜ'ÍG¨’£Y˜ QÚ’&¤¬ãˆ£•Ob,[O‰eÒÂ:ˆ‰uG5ysoOš¶ìŠ…KäRìhÖ–ŒQ¢lTq@¬…mÀOY«ޤ#1Ñéêz?I£­%W‚Q¢ÈZ×X—$¤$í’ÕÖõ•'L–­ˆ,Þ¶'ç~Ò2=È©ìöÛÔƒ•mók€„Ð]‚‰eá¦ÀØl %Éþ‘&WMûÆ^Šoز+ÌÚeå°#ÂV+¥-YQÖ5Ž2lY¬÷'­¢œŽˆW]Qš¼êœ~gíQ\dÈ­5æÃu„Cʶ&ä…"ùÄŠÄ© bì°¥¦ùЇ´ÚÞCØ®’mÎ-X"{ÏG B#t B,Äl­¨€ˆ\±L,¤µpf7‹œaLˆÈ1gâ”eÑ"õõ†D1Ù¼BUXH& ´ä9>y.²i–PŠÏUÖWa£ ôîigN?³¼Oa²ÔM E¶Ô¿òήW—Ä=Û3ªmÚò+BEнz²;kR&­´!íY«8 nÑ—Ør¼*“•‰²|Ìô¸¶8—½;ѶÎÍz‰[tm¶lئ”›éçm>•±¶Àq¡º+lI+Xí³ˆkYD ‘¶d™D˜²ÎڛĄ2!‰˜‰­e¡ •ñDJåÅReźO¹·2é…l±"RÆF­2žh')Ê·RlSã”]rAxìÀ [È7&RïG?Þ¸çÉWÞ_¶©{•WêDMD´bÝJ¼>ÑBR‰ånÚé‡2&Äœ`²¾r%nΙ€-V)mž¥åª«"-–üQ*G¡m9ÅQ©fa¯å«kµzëÞý0›Þdóv Î M8Zè ã1³G›M;͆]2*Ïi5¶Wϵk­G I4ÚÖ­—ÙSP‘·ó²sJ§œæô-·1±NCÉÎ:ziùŽ—ßZRYÝÇ„¢Ê œ]I?¯Àäí»£ ´Ä’Ú]ëlÞZ/n©Qž0±hm©]#TswÓW.îS5'öÞ|Yï'žÚ¼cÛŽu…ƒ$¼{s;/›Þ^Ë‹D 5?‡upŽÐ…Hã ¼}«†R«¯|Uöó,,–I„ÈS᪴ûÄkk·WWMÿôо=ò£Ž_šoˆ†Fê×v“–ë=Ò® »é°ÏW*$ù,:MÎÚjž9ìE©Ý‰q£® s4bm⚃̉à êQT\à†C6å{Uñ`ëvéªÚ·7lÛÕP$¦Ø—#iMݬ³,Ä$ÂÖ²&fbfJæ×‰ä©xa¬fÜ eÃôÑ/Ö-æ„,§ŒìLɆÉ÷>J¿û†ãa×J½+¦=Ëœ¶½SŽÐuÈ~_4ʉr&mI‘X²–§Uš¢)[úÒ{»®Y<ù¤!ç+ëßË©TTÅ‹ÃnE(Ï 4DÂ?û6¼¹ÊÎ[¾qÎ;Û×U¤L‘ޏ¡xu¾»í¬SЧÖoTßHž²rŒpÚ³ñ¾é“ûNÛoÝžµ4ñâ’ºÒä8ñ!¶J‰a2–Ű1ÄDŠI[VIªí©;gLÞ%gŽ6 ìh¶Ö†uÀ†|²½˜ÇŒ\2)ºiRù‹o­{ñýÊÍ^A,RN–{ºápÞû¾w‰œpBh€.Es÷£åXTY%,ÄbÙ¬,‡”ª0žLn™_ÿÏ7·Õë„zò˜HŸ2¥­Iy¼+ek½ðžz»}{ÝÊå¼håÖíWÛEÊÄdê" Êó?}vs§æuù® Yá4ë¤Gq/ℜJ…Ùh{boÒ/\Þ«ðñ¹U•»åT;6Ìâ( ˜…X ±%MÄ–D‹×;¼û ÓzŸbYy̨ p8)ª¶>dç(EtØñ#:Ù7\QÖ§x ¾ÞÖ†Ýyá†B¯ qDæ ¶|“Œ¾F€ãB#t%›J»Æ[f_ÿ“ö.Kcéÿ³wçq–VÕ½ð×Z{?ÏsÆ:§æêy¨žfADePŒ³¾1‰æ&ט“ï½ÆÜ Þ¼QÍ« qŒCEA”‹ scÓMÏsUu×xÆçÙ{­÷SÕTUwÝM7ÒÖú~Χ8Ã3§ø@ÿzí½6 ’±ìMâCïS–ìN®ïy¼úȳ{Lz^¶Øš3ðì–_½é‰ÝËFçâAH9™W… šØû8úZŠjº²ãŒS[39W“¤7áõ›Ýc¿Þµ~gï`9(¦r+ZìÙ§Ì\9 ÙZ&¾ôì&Œæ|ÿÇý=ýhA @ñIkÞä²Âçtd|‚žúcxr×ÐCk{6lázµ˜¹ó «Vwœ>·µ#r­Æ_yA‡/D7ßXN†“ƒ­TùòibTJ©ƒ†F¥”RÓ" ²Ž®®!ãç4 0ÞûñûÒ¤UÇåF/Ì‚ Ì@bÅ¡XB€P%(S’.s." -P¥µ+ç¢hË`&IµB¸/4b“J€‚,èlàR‘¯_ýÖ®óO-DQ}„ãǶÞtwï/~… ¶¢í"ÄÀ‡O¯©ÿðÁgÏ:Å_qáŒsVv¶¦Ì›Ï ÈÑ×o¨ƒ¨!‚gfAÂD+ù vÅë]}^h<Õ9Z»uðúÛ6?±-” BsFf—¾g]Ý·öÌ¥…ÿrå‚峡ô¿õô6ß“ýé-µ½"ˆ("Þ{¢É«6NX}qbñpŠ"íñiת”RêØÓШ”Rjzñ" À"Äòbž™ §59>  ‹ ÄŒâ0íËàS$h=†)6³²™7¾ö”Ûj½IS2&è#‚D — ëÉ©ËÓo¹(MP¯"?ôLÿWnÞ±¡¯Ý…CqäÃtœuL[çS<»mÝîm=oÎ\qzkAê—žžyâÙžûÖŒ kœ5RKSù”îÔ§¥ÈÖÈÜ¿n×7~ôÔÖþÎZØEXA(W=zÓ^uÁƒëJƒ}Ãï¹¼pÉY]ŰvÙyé][×l|¶…Ȉ LZÕ¤l;î6Å>J)¥~&ÿe¡RJ)õ[i,¢4ÆšdÅÂ#8Ôxè½€€ ``Æ„!\–8‚‚‚LX¢zpþŠ®KNËeܾ°P³"‘7¡Ò9+½á⦎ܮJÆ­ï ¾ë¾m»ÒÆf"J§¸)Å6‚Š…!Æœ 楸goÇ7~¼uCŸ# [C¹üfF‚ ZÊ„’ŠX T»üœ9óZÐCøü®ú7nß¶®o± a’5¥æ´o\ •²Aü|oüŸ÷m}z³ggäë—\ÐlaöÆ1æ%nÈDSl©•RêD¡¡Q)¥Ôt1E˜9¢ƒL|Ã!$ @ !Cè ˆÑÔ‰jc(Ö(¨EµZTI  ´7\õ¦ìY+w¶šõéÄ{_,›b?¦MRœé»5e%5àÃ?¸cÓö¢…nH"^j@Cˆe;´taÏG¯.\{N¾=o· ¾{ç¶R L–Í,ΜÙQvq"èÙ G†mø9Í©×,Ïä±NµàÁŸïر+ïaWSaIÂJEXj¶…!Y=£òÇonyçù-©Âà“»†npG¥’ ˜N^Ò>kvÑ{?iÔîAnë‘„FÍŒJ)u¢Ðá©J)¥¦‰ÑÚ–l`äÄÁªšØüfÒ4=@„FUA O¦Ömà€ƒÀ2HM†»ÚÌÇ><ïîûxhï–}iv@Ë®wÅò¶ÎfĸykoùÑuýƒ~®5zŸ¸Z=¬ ¥R1,Ÿ“ýƒ·ÏxͬLu„wa²û »~›ß¸§rÊ<êHåVw—׬Ùd|)Š!¶õ$ÀE‹Ûƒ)òë7Ü÷èöÄÌé·1³˜ióäãdäÔò§×-Y=Ïì«ø†ª?ÊÜÿdÏû.]Ô”²9ëW-®ìØû$å­GòÙ2€ÈÔë•h4TJ©Ÿ†F¥”RÓÂX\ÁÑ'ãÃMŒ’xøë8r0î…qãÇðHPªfj©Ö‘æ ƒ€ß³¯TÊ··æýŒTé—´½áÔÔóëëÛv†$œ9oe˜¶îÙ¼«iò³[vv¥{öµ—!°4röÿ_/›¿¢–Œ ظál=N?µSVuGi©¿~E=7@!‡‚ Ôsbˆ›–®Êaà½øgë#[mG‹-v÷Ü–ê@%¿µ†âÎ%Ÿ¸²eålŽN¥}s¥”«7UlðÔ–¡Å3Š‘¤VÍÝûVK¼°"Cdû!‰¬ËÕ™Oy¦h„"Ú G)¥N•RJM €(€,H‚€âÌñ©‡¡0 ‹ óØã[Ú2ü¶·ž³lFWÎJ¡-ÕÝIB o‹pÑÎ>ã™[Ü÷Ñk;Μ×ýôó#ÿvóH¶…?üîùK;PÌP©ÖvϽ;Ò×}WRè¬$>gÌÈ)‹ä5ÝË"ŸJ9rl8CΕ¶ì®G.hËù÷_±ø5Ýæù­Ãÿô½5]3 ºzE÷¼’ *{«…[oßñè©&Á}ÃûÊÄ-`³ù|*qɉ03“°€Í„rô·OëJ)uÐШ”RjzÉs'ÁŽWhl„UÄ µ>þÌà¦ík®~ýü“WD³ÛÒ9S,a‡E,zÉXê[ÐiÏYbçEûf¯hm/ÔlaöŠ™r¥’½å[nýéðpœMlà\Ú;Ë”8`#5DB’È%ר d º8ø=s‹xÞ²Ž…·°9_lšeí©³À"ï¬ÐîÞt÷½Ã53/!‹âÈ3øÆ÷„ˆÑ‰ŠÁF‘…䀲¬RJ©ß2•RJM" ‚£U' H4óÑ‚€ˆ$(^XÐÄÔÍÌûRÿzÃú®Ùõ• ‹‹fåÚ›[R©T-.Ïž™×iC˅Ȧl°£¯gÍæz×’B*ôç,“¶” Wê·Þ3ró}µ½¾&Qä½ÏD¶%ZtÃ}ÕüÚíÕ4B¦&8`½Ï-¤æÏmaªÎ,$»¾þÊSÏ÷Ì>=“ýyËšºHF†âÌM?ÛrËÏj®S"ã@Rè›rÀç‡FFª±ãÀ €#`?ÊtÊ{7ñ#œª”R' J)¥¦±œØÈ*‡Y"ÃÃ/D8gO„ ZBL DPÝ„ZEkû÷ýºgØJ¿5;,!ÖçŠï{Ë¢4ÕVÏÄÅÜú¡ê¿Üú‚¹â´KVH(iŸ˜[îÞsÓÏ¢­>Ϻ ¡ÚšNväB¨‚Kÿø>÷o7?…kC¡±NÞá¢÷t΃t·@*4›áß~ò\&µè¢Õ3RAÂ쇪Á Þz×ð€ï¢(ðI 92Rš?{š2¸üî=I-aO1 ! 1:BĉapüËI¹pâ–íI¤”RêUGC£RJ©iaÿ€Úß‘tõ”£[¼P@< &@Æ.6µØ'gI}¾Ù¥ë1$!@:ɱçÊ‹;: %qíÛJ/lï L](Î:Bä-{vnïͮȻl6sñ9¿ÚñÜ@ܵ³Üõµ[ö>·u >òx2’t&Æ\ |:Œ¹˜­½íÒùÅ\x·m¨ã²d<³E&Àø²j…ZeTJ©½ô&J)¥Ôo ”Ñ'ãÇ©&Td"¶† `lŒ¬C¬YˆCŽ#vÆ#bS_9úé£=YS{íªæ·œa;é‰Pj;†Û¿sÝùp¡Ÿ›!S"¬œ²É®–pÇÛ^_8ÿ´¶š“~òhyO™ ë”  eÁ£7{jöGmÙç²I8rþê–÷œ=¯}dkž°§\¼áÞÒÖ÷ú"fo Eq’•mŸsr!2Rsù»Þ=0LÆCÂÌì@DFýÈh[Ú#}ì¿J)¥^Ý´Ò¨”RJ7˜!#²% d$ PG1›Šõ†Á¥½Ë$Ø'q›9]îÊó«W_8'oçkO¬/ßñоA)¦,°‡°.ÌÅ} €s IDATúë Ý몗œ”i‹â]4kÑ·ï_¨fÊéVbäò1:G ¹¨ïWv\rAg>5(ÌϬ‘{îÁ´À°ã„ÇþÆYð¨î*¥”:hhTJ)5-€qÂ""Œ€z´L*x0ã‘8Ø¡LQ&I$Þ&‰qe ¼±lbG‚Ì@hGràz+_½!^þá-‹:[ &÷‹–¼v®¿û—›žÚ¾sOìF o‘Û‹;Wv/»òœ¥+@Ö8ïñÙmrým{zªmˆ³1‹&Ã(&3ˆèwï-~ï¶áSCW˜ÕVHÓÛÞ8{îüÊ=O¼ðØúm•´  SµÈöŸ²¸~ù™­«—ùÐô04ýr+}ùîÍO×rAX  ‚XBD¬Š0‰‘É÷gŠ_Ãñ_S)¥Ô±¦¡Q)¥ÔtÁ2ºÎƒ Ñò‚¶¢‰Î¤å&@âÀybC(,Q±µ1N“ÈxÉööÂWnÚòö+ºVÏKe3|ê*^½|Îî9»†]¿3©¬íÌÚ‘K0À §~½µöõ>÷ôdê°X`F±È‹Ô avóöÊ—þs݇>xñÜY˜Â‘ÓNó«Nž³¯AßÖ]äÐ5·„sÛ e •XRÛz¯þçSk·gë.uD±[ÁиnìÆLu/Çß®ñÏ_bG¥”R¯•RJ©cæÀ<‰€cËB¾øÞQiÒŽ÷<Ù¼±gÛµ—ϼðäbWVRA2§#˜Ý²XACb\q_{nð?^¿acԙ˜%9ÔªS÷ínÞðÕ‡®¹bÉ…'[Ðe­›Ó޳[ æ1ðˆ€PãìP-ûÈÓ}·þôÉÍ»2† ŸöAmÒõõ ÿÙaD)¥Ôo–†F¥”RꘙPIk¼Óx6qùÆ—Þ7Joïq_ûî¦gŸ.=oÞâ™©Ö „˜X d׸?‰µeÏ}Ïî~ø™òp­Ë­À"\F{ȶ>‚¦jÛ·öV¿öí=kŸôçœÔºª;l+ E!BdŠc鯹_ï¨þä k6 TÓ„MÁb©4å—>Ì2ùåÕz•RJýæhhTJ)¥Ž‹ý‘q45ÊØK™0–uR¸Jqb¨T£s=ý\W—ŸÙžnÉ7e£()•÷ì©n®÷s¾ÂsļØâȘ€RÇ}¤%2>_«Âý÷WyxCk›ëœ¶we£” ªÉÐÞÒîA·±§6ì²1v">e‡lÈȹC|¹FBªÎ!‡§jfTJ©„†F¥”RÓ!"cDäÀ ØT)Û¤ƒ8ïİKo)«,Â➦À׳lc|‚é:zwò3ÛCD!JŽ¥#‘£%#5‚±'FfäqÅMi4;E“òyǃ–%W†F¢ eã7V¬Û„#ŽÈæœX´!zGèŽYbB’™p3'ÍÝwí“î$ó„9D/®õ… :¥Q)¥N•RJ©)§Þ½¼™ó2º82zÏ€HÎ7U @z’}–@ •l~“¬áŒg¥LX!JH"„ b2šçÆÒ±:'—±L¡Cdh- @6¬ÃÈ0’ç\€ì F€™D|pè;€€“j‰‡{F§{*¥”zµÓШ”Rjš:&¥Å—D„8ß8µu@œªLœª`¬¶Fi¬6¥ú;ZMs!‰!²Û†+½»z Î! ‰ôHe ²"(K-.×¹.à-A$b#àFÆRd|ýÆO=V5[¥”R¯•RJMGGm¦Þ'ŽÛ$"D€8޽0 „Qh {‰ÈrÞH) ­ZØ|æŠö“—GÍÔ”£$Æ âJÍmݯÝR~詾 ;¥¿0eq$‹@‡8*Fƒ‰5I{K8oFg[Z³6…H«'•j®o Þ´cph$Ž™  ZƉ#PÇ:E@4‡së&Óh©”R' J)¥¦ A`†±á”chŽ4»à‘ìÂdê±Ï.“¶ñN„- €@AA&1³Zš®¸xæÅgáŒfÎÛZ >4,®˜)u.Ê.ëî8ùäöÇÖÆ7ÿd`ÇÞXã I£ñ"ˆˆ-pºžm*Ôº¦fuÙ¦”‹Äï- >€ºå3™b&_,¶ìÚ;²aóîòÈ BD`%hüalYÆÑÄ»¿Aì!‡§NJÑ0î(:6U)¥N•RJM (} ¼÷˜r‚@ ¡€à)ë~H0¾} jM €IqRFŒ´TMfSo«Ê.œ›kÎîÜCÊåk中n0±‰ãÑA™]E\î”îº|ÉÅ«ÒA´/&¨¸4¸Üà@Ò›T9kÚ ]Y š™Ï,Êég¦OÊÕ¿òãÍïHUaAÍxkFBæÀÙœ‡’—–Ú*´ƒžù©æUËÛ:Z}ˆ©“Ç ‚áÔj¦ľ=,¦0Ìr=“.Ï›ï»[Ò=½¯§?夭&djDÄÆTH@ ÝnÆçDßaìv°V•Rê„ ¡Q)¥Ô´$" ¢ÙÔÑEyò;‡‡¡€âHÜ®7P1…|fF»¿ðÔÙÛo¯H¦µ?µe/p€b3dPÄèœíê þÛ;4;b-öÅvÇw=Ø÷ü3{úêÀ6Ãs ~å’®7œ½paW}§œæ>мtä?v>¿³äŒp而%!Lª!”‹Mþ´¹|¾ÎPå ÚÛçvíìé÷½ÃC1ÔSÆ·döµŠÝ³;[Šy/¥¦BÓêU¿fï¾^‘Ôh\Ä#«´än6î¸4¢÷Tku(¥”z•ÐШ”Rjº’ÑS„FUíÅsApFÈYùm»zŸÚR™1#råÜE3žhÙùÜPRÊF‡…I–|-”¼oÍÊÕo*®žŸ¤¸^©¥ï{¦ç†6?·-rÕ6†ÐgDêÃ#Û‡žßVzlÍ3o¾´íÂ3l{®rê¼â»®XðÙëw&Â`É!X ‚\*¦ë+ç6·ë ;‡vã®Ág×ô•ʹÄç<ÐBÕ»í•úÝ}Õe‹Š3ºRd]kKfÙââSÏlNf bc­ ŒL•Ã}2ºƒŒþC42*¥Ô âPså•RJ©ßf2Ze8¬Ž8²ÿ£ƒ*_ú %ÈH>]N²÷>³«§XÉœ<¿põùMÔG>‚‚à‘b‡â™â}§,†×’6†‡}ôÃû·|ákÛ ÑŒZTLbB[©¤²C¶ù™=Ñõ7o¼ã}q½¥Éà+ ž”Ka ˜‡&ASg“8o¥º°=;¿%±—Ù¼µôø¯{{«©2fk`ÅX"“`®n;â°­·L<³yÃö~4C<£#Z03@H@œ÷^²N;j,eʸI‡»¯RJ©ß, J)¥¦iô†7ßat€äaÔ»&§Áý©s#KDT3¦Tãðágj÷>¶/N‚l.~ýEék/nžKåBì¬$ž81y¦|1Ïo¼ kNn lÌ#Û¿ùÀÆu•æjÔ{bŽI*¡DWªøTͧZv,¸áŽòšç VZZ®»"×Õ Æ”Ð3:`ƒ"Hæµçšƒ€¸­wY·¾:\.:ÛZEë¬gS¨AbƒªPmÒÏoÚ·kwÕǘ"Xº #²ˆœ˜Fˆ klŒ‹‚S— /þA]œC)¥N•RJM €„D4î5"ŽfÉ Øw|Plì7ú ‰rÒƒ|DT;(ÖTã®Þ1°fKÝQ=W¼æ ù\Ôº¢5‰Ü.¤½‚Î -\йt~s:áþ’Üù‹uÛÓ1ÁKë!xCiç›\’G c<&Õ_qÓ{ÐR2§Cfw¤ ‹ñB®b¤¢N:Š©–|DÌuO[wì.¡—ŒçÈ"!“÷‰çš7.!ˆ1©Ú[úOS!›n.fAœ!faA!½‡—…Ç×ñ]&MUJ)õj¤¡Q)¥Ô´`­ÝŸGËáäC>àÐ!K5ò 0ÚÕÏ_½ùùõ=©Ó-õ·_Úô§\yéÙÍ3󽦶ÁlZ4'Û’5Ä-ý;iË $³³ËÆýy·]o\ÆBÕ7SSƒ#¹ÚvË{j&xf{¼ioÂj!S[¶¨ ]5jš†S²7äÁ€‹y1@ÒSîÝ5Ð# ½³ÌĆ8 ¾Ù°µP#ˆ=ƒ9½«§:4â‰HZŠYKà“º°§Ñm#2ýS"0[C)¥Ô«6ÂQJ)5-cƦÕñTkfÔäî©ãM5%2”ayb Ÿ|øøÒ³~çŠ×, ¢Tÿâù…Ï?k_YvïNFú†–Ï/Zóôsг7Ë>Ý’­\rj~Åü–;îÛò쮂.ð°j_sæŒ}}å>QÙìÊ=8üÔΑ“WtY[í^¤£j&uÍÉæ ¹&Œ±5Ï 'Â}U¬²8$@` Î =xöä@$@ŠÓÞ‘öÖ0Ár!×$  î¿‚""‡3Â÷À{DD:‡X¯Y?Ð’9û5Kº›4M»÷âŽ>b,€ý㺽Ԣ—übC!@`!'¾–ËXm„£”R'Ó¨”RjZ Ìlφ† xa¡'" p+Ô¿“¦GË’²P5PBð¶blÞy¨u|÷ÖþæŽì™ËšòÁÞן.Z±ê¡g÷=ûÄΛKµ¸˜Éæ:[ð5g/=ïäæùù8€R£ûÖ}ùÆ5ý¥N ¤F∑˜AÐ2`©R_»nGSÆÎÑJ2³Ã4µ,غ—wõT‡J!KhM˜Y32³g¦RéÄ  7l-?þô®j=l $m4±MË££z§ì‚#QÀûG±Š€€Añ>élk C·®”RêXÑШ”RjZ ‚¶f›Ï¥ªÞyŒÑX;[7ãØÏ`ÜŸ ̤ 9oj‘`Ä!¢GáØ×B¶l‘/ÝúB-é¾t¹ÍEÕ%Ívö¹3®<©½V®—½±aÐd1Û  JÿHxߦê—oß°©?[0éÄÕÉD$Q-¢1 ذRãGípN{* z*ª,˜•ÕQ â¦4{'![ ‚áÀ–1Žs½òÌs=UËVR£ë†ŒÎfahKð sBÇÝlÔ"ÝSÉ‚÷(ÀmÖÔV8J)õê§¡Q)¥Ôt‘͘bS¾Ô_&1£]?¡±„D#û3Ž“ÖîðÁ@-¨RÚ8E>H ìMâ‰ÝãÛ±ô‡†ÏÈ]wÙYÙb6ðÐä½·‰÷$)%@¤ÛîÝùµû†7Æ3Ðd$®Z,BŒ(hŒ0Êè·#2éÞá⯞žå–.LG9¨d žõõ˜Zë6…2P×£Mëw¿°Õ•\ªBÞ‡àƒÖbå%J´/~Ž„È,„’ Ãæ–ã[ÚUJ)u¬hhTJ)5- øºÕEØ×3k(ì»'JR·@ý.é&SE¬Åa§žÙhðż cíòÕ(_Æ6ôF|Ú¸Æç!©ÕÔßß;tÃóÏŸsrÛÙ«[f6¥:‹…}µšô×qÏ ýrMõ¾Çölï‹Ù²P'q±D(Ü*Aœ€tHT{êзÉÿzomöÌBgk&m’\6;I‰m96IÒÒ»ghÇö½å’!pQSlªã¾óþ[z/>iy’t5]³\ ЀKqµÊ‡Õ°ÚzR¶tF»ŒÖ)•RJ½ºihTJ)5]ärá’%-oò϶Qú°@1¼ÊFI—IÌΕÛ÷lyð¾M)×ÞeBIœôŒø}¥x jªœq& hÄ“°4É€ˆx¬VK¥­–#ù´ ¬8;RgÏ×Ù; , òaˆÐÀžHÉg,$³geóùð¨ïRJ©W’†F¥”RÓ„¤R¸`^ÜVƒ¡Ø HˆX<‚¡’ÇuýƆ€-{@@©z«ñîRýùÞ:¢à„XL)PA‹Œâ x'4¾é¤K%F!¤”g©Ç3”œˆ°X¡@X¼áXÐÂXßÔ#ƈ€Ž°XB’$4<0~s6o-3*¥Ô‰@C£RJ©é /˜›žÑì+})ÃXg4)  Jt˜¹hrwÐã †M@^$ö"HdsÎäXD‰%v^H ˆ@Q< ˆ¼¸ŽÅ¤Ë3^1!‡H`…Ȱ!àAœ#2€lô¿9ÜÐx@“XtVj’"Bãòùpç’E]hXÜË¿UJ)¥Ž; J)¥¦ßÑlWtgwï«V$¨1  ƒG‹ŽOƒZZ /,`˜Ñx‰lÆxaO , qê+C!$@옄KF"ˆƒáѳø—³ª³'AðVƒg hŠÊsfe¼mS)¥Ô+IC£RJ©éQÄt'/Ë?þìP= dáÆ2ÇÖeIDß3frC  ˆ€ˆóP@$0H3AdÀÆœCĈ–°‡¸OØØF< ``æ 7@€„ލ]ͤF8œ4‚ àPس¯õ.^™îhMy~‰ˆ«”RêUBC£RJ©éA³†Vtg»ŠÕ¾Ý0b,¾œbÚ±¹ºI£:9×Èm@ @‚8šä¼‘2ƒÙÿSÇ–R<(Fd‡Â/ÎZÄÆÜE 4c‡i¼<ÜFA“¿ˆ©FãRB(蜫7åú/¿dbªêhêÅ:”RJ½JhhTJ)5]ˆØ$ÁY]tÆ©ëz†AÒ‚‘Ó ¯t÷Ô©çF Ûýå=‘ÆÓÑ ¢Gð4qõI ½xèÞ©èkF¢ ‚Š4ö00TFS*òᇻI_„)’·„ P‚ä–¯—.n÷L+RJ)õê§¡Q)¥Ô4NÈ ¦ƒø´S:o{ º·B^ѧkrØÅ´ceªÁ«ˆ€# Ðþ S E„¡N@_b¶¥:DA6 ˆ‚„‚žDFO!~6Æmºr()ãO Æ1¸³Ïš•Í™ã0X)¥Ôñ¢¡Q)¥Ô´Àˆ1 cÈvÏ’sÏL﾿\vB XA|qlçè\;A/è%šp¬ ѬQš›¬ßxbÖ:ôlÃ*cWâAöŸEX¨¥1„F뀓4"^° ÀØ|ÅF’„‰Yqÿcªa¤b°Ñ¾„„A®‡Ì$Æù37Ÿµzž&F¥”:±hhTJ)5-8öàE G­Ù¡7_’»û¡¾²’,9Ü…ÐDb¡±È € 3:@¶Œ?Ô„ì7Ú'fÜ"ðbh<’ ݸàX÷ó‹3˜ÆEp,‡1¡:âŽ3htI›ÈÙ8ôÁ§u¾ô7 C"Œà ™„È£a¤È… Tëax›«×œ¿ %«S•Rêóž÷¯”RJ½2XÐ 2@ÍcÕåçu´_~Á‚,ôr\‹Ázd‰GçÉyL™EBôYòY™ÂXãÓqoLÈGçÀcæÆÇ𰇱ÐËÒ‚(„ŒB!Z„Bž´¼éôÓÛ´÷RJp44*¥”šöî°ä, \ýºù§Ì zJ%½Gñ™É0Š!ŸE—¡ýaÿrR!î8D²—pD':—‡Àˆ €ôŒ®=HB$@‚1³p:ˆ±37ò®«ç2ºÌÆËr÷]?yôá‡ÏïºóŽÇyø7{=J©iBC£RJ©é5@ˆt´šw^±´5;€ ,Ä`¼ä<9¦Ð!Š9°xèº#Œßæ¨/U„Ç?ÆM/<È1'^ÀTã< 5åAD#`E˜«©U.¿8»zIšOØÙŒÖZï_éVIºá[ßüé·5žûë_¹ëÎÛ_þ1ïûÙÝ_þÂç^þq^aÞ{kuš•R¯ J)¥¦a`/ÈcÝ@˪eùË/éÊ.1Œ(!{›8pŒuÁ*@ Áqk~ƒvÁÙñGz½G›ýð€° È×#"<ΤOy¢‰9¹Ñƒ‡@…Åy#lÅl2àWvó›.nMñ‰[fÌ7F†‡~ÓW1ÁŸþÅÿ¸î]ï{ùÇynÍÓwÞ~ÛË?Î+lxh0ßTøM_…RÓ…†F¥”RÓ‚l Ã|6¹öMË.>©˜òCè*èÄHÊbV€cÀ*P°>q§C&Æc{½GÇ=àÅòã9ö UÓ)>}7z·Ž­Î$ˆ9¶R «ñ¼öÿþi³›3ÎOè'tb™;ÁÆ ë_É3öïÛûäã :©®:ù”…‹Ç;·qÃúÞž=“¶,WÊq½Ò  IDAT•jeýÚçJ##û?ªT+Iœ@¹4â’äÀ³xç‰022üüskjÕêø}+åÒø™¹\Iâøð¿æQÛ´aý¼…ݯÀ‰”R ÝS•RJM[†Ä›‘§·_Ô¹§·gݶẠ½DŒä¹7ZŒ"ðáp¬Ô6ŧ‡|9Þ‘—"“âù±= ‰  ‚r`Ñ€óqmqgöÝo_¼`f»ÈË Ü7uõ)§>ñè#¯Ì¹jÕê¿|îî¾ë'@DËW­þèýx÷¢%“6ûàï\»tÅŠOþÕß6^Þòƒ¿vý—ápƬÙþÉO-[¹ªñÑG>ðž³Ï=ÏsëMßo$À«Þ~Ýïÿ—ÀŸýÑG7¾°®¹âÒ?úø'®xëU“ÎrÅ%ç¿ã]ïݲyã¯ù%3‡aøüñ7]ùVø×þÜÝ?½óÛ?¸µ¥µ­±ñ?ºõ‹Ÿû‡ÿóù9ù´ÓϽyÑ=ºê¤“÷Y”R ZiTJ)5]¡0’•øŒEÁï¾{Ùü™`ü`ˆ€Þ‡À¡€0/õÿÊCVç&öR&] âñ’•F÷‰ÏI“(‘ Ø ³Ct..7å‚kß2ãü3Ú’Ä$|'F¸ðõo|êWöõö¼çú×/~þž»ïúÃý·o}ÿ–?ûä§öìÚõ™Ojê]nùÁÿúÅÏ_xÑë¿pý×þùË_éèìüÄÇþp˦û7¸óöÛÖþú¹üâ—ÿõß>ûÜónþþ ë×­€OþÕÿ¾ð’ׇaøå¯}ë‚‹/9èÁoºá»éLæ_¾òþ·¯ž|êé_øÇÏ<ûô“pÉ¥—3óƒ÷ß·Ëûþ³¶öŽÕ§œz î”z{ö<ùØ#¯{ÃeÇûDJ© J)¥¦©˜±šXfc°oywøÁ÷ž>³#ík J6"'ð ÊãdâðT!l¬´áH×ý5לvñ…MhÀË ÿç‹/½,—oúÞ|ýxŸh÷ÎwÝyÇåoyÛ•W]ÓÞÑyÑë/}ûï¼g×Îí»wí<Ô.ÕJõÛßøÊŠU'ýéÿK–._ºbå§þîXäÇ?ºeÿ6™Löïÿñ +V4A÷{?ôaؼq#Ìœ=§P(â‚îEù|ÓAßÖÞñ§ñ?v/^ºlÅÿÔ_çóM7~ç[°ú”S[ÛÚøùÏ› <ûÔ“½þR¢ãþë¾á[ßh*/zÃ÷‰”R :±ÿÍ™3g=¿víþ—K—/OgÒcÍ€¾¦>ÊòU«Â0l<Ïds‹–,ݶu ÑE¯¿ô‡7þçàÀ@±¹ùÁûïcæ‹ø÷/þÓ»?ø»©túxŸK)Õ ¡Q)¥Ôt†,Pë“ö\Àgž”B³ä[7?÷äºz­^ð°©‘‡rÚ‚ðèÆ6N ú)ç4N<Ù„ç‚ãÞ‰y9‹uæ¾O;q‹Ÿ"•Fßk+€!‘Ð3cGò‹æÅï|ó¢‹ÎEÄò[‘þøÏÿòûßýÖçþî¯?ýŸ?~gééÙ sçÏ?ü]öìÞ ?¹ý¶»î¼cüû3gÎÞÿ<—ÍïNÔèt¸ÿ–Íœ5güËή®Ö?ßx~É¥—ýà{ßyðþŸ_ñÖ«øù=óv/è^tøW~t>û·ŸFÄ?ú³¿8Þ'RJí§¡Q)¥”ÇÁp]B“œ±2èì:åë7¬¿ÿWû†]†­IÐsT1 –%J=Ib ¶âŒ'ëý`<þÁßûÈù(<ûÔß¼þËŸù—u½ ¥^I'ü¥”Rê˜`Àº·qs[é#ïYúÿ¼k΢9ÃA2Â1 ¥kå(ŽÇä À2…Žg·4ål½©Zݼ<Äcj“—Ã8´—èÄ3ñPžó^2Fðä=9OΓx$ï=%qÚ']éÒÛ.jù³?8ýô¥yðø[–®{ï^{þ…|ÇU=»w§S,Z²žyêñýï|ë«×ÿÉG>ìüAÖÃh˜;=òðƒû߉ëõ}ôÃßüêõÇä’ž_³fÿÒåÒÈš§žš=oÞþO/¹ô²gžzâÇ·ßÇ{ljÏî]|ÇU¯=ÿÂëÞûãz"¥Ô$•RJ©QàcÇ]ÿÖ×ÍüÈ»WŸ:ßfÝ K&ưfL%Àª•„D6™ÄN]f<êÄø²¾ÅQ™ú ΰ3>1œð„– s“ø“˜œÛ3¯Øû¡ëf¿ÿÚ¥Kæ¶WãTÌÑ+ö}_IDtýwnÌfs|ÇU•rùxœ¢{Ñ’³Ï=ï®;nÿÅÏïíëí¹í‡?¸óöÛÎ9ÿ‚Cu©€––Ö·\}íã=òrG¹4òèÃýÏ?ÿø ëž?ç¼ çŒÍÍ-I?ü‹kBÞüý~÷=ï\¿ö¹ýlܰþKÿü¹];vìØ¾õïþê$ÞýÎ{ß¿ÿÓ×]òùî7¾zêég´¶µ¿Œ¯þ*åòßqU6›»þ;7¾½v”RãéðT¥”Rj‚„‘ëž»¢eùÇÎxè‰=7Þgž[·ÓÖYH"dïC"0à€½cd8èB‹2©µÌè€ÏÑ-'OyùÐ!S¦šñØ(!Î×<àb'/)‰ˆˆHDˆX zBô‘¸}ØÙ»¤N”Ìl’k.\ô†Kæt´ÇLµnåoƒ¦Bñ?nºíʋνö²‹¿~ãÍ3fóS|ⓟú‡¿ýôß|ê//Ï>÷¼ýÙ_N½Ë‡~ï£qöïÿæ³0kÎÜÿõ¿?³xé²Ã9ÝkÏ¿àÿþôÇŸþä'ë4 îØ¾µV¯ïßà —]±vͳºå&Èf²ò‰¿œ=çÅJc{GçI§œúô“O\þæCŽ}ù5ÆÛ¶þèÞ› Åãw"¥ÔAá+ùwŸJ)¥Ôñ°swï£O=wÖ…ÛÄB”°¯ìÙýègïüÅŽíC­µ`NÕ#S5 m’OíX“þ¯:)ûˆ8Å‚‹F¸Ã¼æ£®jt¯ýI¸ŒÉB’¡$eªì.+ËV´½ûM§/벑L™fËlÝ´ñ}×¼¥\.}ýÆ›ÓüÆá¡Á];vttuµ´¶æ.ý›7nL¥¢¥ËWsd cöõö´´¶¸×e¾öïyÿ>ü»wíÜ××·pÑ¢L67i›/|öÿ<üÀýßþÁ­Æ—jijO=Ѩ1þÇM·Í[Ø}ø;>rß}gž²rÖŒŽãqUJM+ZiTJ)¥Žœç4ÉÜBéƒW-¾èœÅ?|÷=Olß¾—‡k&®¡`ScÞࡺŒ«Y}GÕa÷g`I%Eð€.NS-%Å\éÔ“š^wáŠÅ‹Š…È'±q|ø§ým0oa÷~þàï¿çº7_øÚ÷ÿÞGþô“Ÿ:æëp4ŠGZRknni>£åèN×ÞÑ9õ3fΚ1sÖïWª•Ÿÿìî·^óö㑇>û·Ÿþæõ_>ç‚×ýÛ·oУR¿)•RJ© qtÙŠ  €Îñ çt¾ýM]O?ßÿèÓ½Ï<_ݳO“¨ÂcãBõÆ— º§8àóX„ÑI5ÉI—ݘ3¶¿eNÎK:Š£ÌàÊÅÙSW´{Æêų²è™$©&Tã#+jývh*¿së7|ëŸùÔ'o¹ñ{þ£]÷Þttvý¦¯ëõéO~b׎põ;ÞylÜÛ³ç†o}ãß¿øOˆø™/|ùº÷~@ç1*õ¤ÃS•RJðgx*!À¡¦ " €A ‚cIkëì9®õxGoèÅÍ{ü¦Í½=}ƒÃå$vXOØ3ŠØîqtåŠÆðÔÉ—0aSž0Ösïʆ)ú’6æ¸ÏE³ €&ì9GGÉâØ…‰b„$a>—BcÌÒvZµzÖò¥™Ù3°%ËèCIB¬ˆTøÓŽ]̦þœ@F†‡¾øÿ~æ;_ÿÊðÐà)gœyúYg/ì^\(6éÑW§{îþé¼ »»ôÓ;o¿­£³sùÊÕ™LæåŸË{?8пyÓ†ÇùåS¿z´©Püÿٻϰ(®¿ãg—Þ{•&MÅÞ{7v5öÄh)–¨±Åk,‰&¶cìc/ˆ½w±aG¥Ø) ,ìò¼Ø<+`]Ê÷sùbwæÌ9¿™9r3³³} úfÔ…þv nOŠ ¡PâAh”ü—s¤¡#‘!”™™ªàöß !‘œ—33 ebrZRŠ">9C–ªLISf($™J‰Hÿg8Éë•™ßÅBIf¶ÿó.k ÌÖò¿3ÿ? ªB£P=“GúÿÍ%ê:²ô ‘)$B©+U#C‰‰±ž"C.•Hì,uMMMuÿÿþì×>™™ eö|(•ˆ|?âXjB£JjJÊ‘ûÜ=ärÄ£‡ ¯â …¶‹Ê…½£³âYÌ»úÊ·!•JÍ-,ÝË{V®Z­i«¶Í[·504|› @QáöT@™w8Q'e¦ÈÐv2Ez®«$RS#Saó¿n@ûÒ…HW4á$ –š¸¨bhdôA§.tz‡O-ûƒ‚…mÚ¶Öv!JîhDhhDhhDhhDhhÄÓSÅщS§Ož>efjæíåùaçNVVVÙp×¶íêÔoX$elÙ´Þϯb¥*Uómy08ÈÜ̼v½úïh”s§OÞ¼qýÑÃŽŽ+V®œõ{äƒ÷í±²¶®U§^Î ßæ€l\·:°JÕŠ• ¾IrRâ }†tè\ˆEžûÐ ®4Š1&6ü؉“©©©7oßþmé²Ö;Ÿ:s¦ Ûþ³aíñÃ‡Šª’5+ÿ¹t¡ -7­ù{ÿžïb”„„WS'Œ™4vÔÎmÿÄÅÅ Þ?kÚzuÛ¿w·ºÍÚ¿Vïûïí±C—,œ¯^õ6dÕò%×®„¼Ñ&‡/]´àןg=zVÀM²œu_Å¡P¼ìÚ³wßþàÁƒîÛ±mÅ’Å;þÙ´yík«QcÇ'%'k»:-˜6aì¹Ó§>ûò«÷\¸tÅ?»‚._éêî±`öô‹çϪڌüaÂǽû«^‡Þ¸ºow!ãëÛ;°oƒ££âàþ}Ü$[ÁY÷PÅË™s猌>í×O"‘¨–øx{õêÑ#)9ùö;Y[>ñâìùóñ¯^åÚ,Ev+ôzRbbÎUŠŒŒ°ûwŸ=ÉuÃYJDø#¥R™½Ã䤴ÔTõ[¥R™œ”˜‘ž®iG 7J6Ǽ~5¤ß€A=z÷“J¥B©Tê[ÁÄè±R©tßÎíªf•ªTõôöBÈRdéòt!DÎÚr= Ü©¨'C¯_M–åÚ= »{çV÷^}½¼} ιw±/_„\ºðú”å,X½/j ¯â¯\¾xÿît¹<ëòdY²<-MÕÉÝ[¡¹žkÀÛã3€â%C‘‘’’rãæÍÚ5k¨vû°kõªU]]ʩަ¤¤ü4kö®½û„R©ÔßϯQÓ–Y{˜;}Ê‘C …¢Gï~Ÿ}ù•zíö-›W._¬ Nå\ÆŒŸäPIµ*].Ÿ?{ú±Ã•J¥©™Ù§ƒ¾ÌZØ€Þ=jÖª3jÂ$ÕÛ˜¨ÈÏúô9vB«¶ísîE¡GÉfë–æ–]»÷̶Ü×?`æ¼…2™ì¿Úzu«P±âøÉÓG}óUؽ;BˆÚ·þfÄèö»æ}@òݩؗ/†}õùíÐB]=½_íÚýcMÕØ·WWO¯qó–2™lå²Å×®\®Z½¦jUjJÊoóç ªSV©òWßðòöÍY°z_„r¹|éÂù{wíPubmcûÝ÷cÔŸÏòiߺ êèèìø÷U@íÚýã/¿–ÇñWÅK«æÍ…£ÆÿyÁ/'NNJJB˜W®`ii©j3{Þü=AûǼ{çŒ)“cbžnùg£º‡G?{ölÜ”é³, ¬²yýU,Blß²yé¢Mšµ\¸|å¯KVØ;8Œ6Týé»_æÎ:~ôð€/†,_½aØ÷c×ÿýWÖ«pW„£</ïéi`h˜sU•ê5ê5l”máøÉÓš´h©¯¯¿dåšÆÍ[ä{@òµsë33³ó.]µ¶F­:Ë~ûåÒ…ó¹¶T(‡ƒƒj×­gnnѤyKñ¿w¨.]´àðÁà¡Ã¾_óÏöQã'ÅDEÍš2ISÁ¯·Z8?8hïȱ¶ì^²r»Gù©dž?z n°o÷Î[7Cç.Z²tÕÚº nûgÓÝ;· ¸k€âJ# xiÕ¢ù´I—ÿùךõÖ¬ß •J}½½;uhß©}{ss3!Äã'O¶ïÚÝíî=»wB´kÛæQÄãåþéä\Nafn1eæ\#c#!„©©Ùןvÿ¾—O…YÊÚU+*V 9v‚j¬I3æôèØvï®í_}72&:êÈýöèÕ½W_!„›»‡TGgê„1oZŽ+KNrtrV/ ô`ÚUYÛ ø|ˆƒ£“ú­³‹«……¥HÊ{y«j: Ù ~§Îš§ºUøûq?îôÁÖMëjÔª³åÅsgãâb›·þ@áàèT¡bÀ©ãG¾>ÊÀÐ0:òIð¾=têÒ±ëGBˆf-[¿ŠÿcñÂè¨È\ V‰‰Ž Ú³«m‡NªËž¦ff?þ4»[ÇÖëÿþkì¤iª6ÆÆ&3ç.TíZ¿Ï>?{êäð0ß þÙ5@ÅNç:wèqérÈÅË!‡3Áßk×­ÿû/;[Û[·ï(•ÊšÕª½nß±““«—*1 !¼¼½U)B¡J\Ïb¢…áÃ’½}}¯†\Voëì\îö­[BˆÐkW•Je£fÍÕ«êÔ«¯úá)ÂQt¤R!DFz†zIº<=îåKÕëØ—±#uû¸OÖИ+M¤ jÖ©«þp©™™¹§·Ï“Çsmy h·‘±‘¯_lìK!DÍZuîÜ =}òx³–­ïß½«T*_Ÿ².ÝztéÖ#ï¡ïÞ¾¥T*«T}}—²‘±‘Gy¯ð‡ÕK*øû«wÍÙÙUñ\ÇH…Fh/J¥R"‘H$w77w7·»tNMM]´d©êÂãˆo¿‰ŠŽBx–/¯©KKkõk©T"„ÈÌÌBÄDG !‚vï Þ·'k{gg!„ê‰5Y·ÕÑÕ54Èå¾Pyº<×åE8й…¥¹¹EÌÓ×ÏÛ·Âì_~W½^ÿ÷ÊÕ+ÿÐT^VšHN9wÊÌÜ"ë[77³gNåÜ0!áÕÙÓ§2ÒÓ?ù¸kÖå‡öïkÖ²õÓ§ÑB7‚T«ö4&Záëÿ?— mlmîgy’©‰™úuÞ»(4B# ‰õªqËÖ}z~ÿûÏåB/o!Äõ+¯ï)½rñâÿÖfùüùSõÛÐkWÞÅ(Ùôìû‰"#cåò%Ù¾¾âڕ˧OÏcÃÊw§®_½ªúU|Ü“Çá+UÎÙÏ =ŽŽí;X£Vmõ¿:vV*•ÇðöõB\»’å”ý¹|øÏó>eå=½„!—/¨—ÄDGÅÆ¾ÌµÀ»Ch/#¾ý&2*êÓA_?y**:úQxøÞ ýƒ† 566j×¶ÂÏ×·iãFÛwî:pèpÌÓ§6ÿìï`ffžwÏÖÖ6>ìvé¹A{’“ÏŸ9=q̈{wn×oØXQ¡b@Zµƒöì:vø`Rbâ«W¶n^¯«§§ÞÜÇß?ôÆõƒA{ÃÃnûgÓÞ,ßG_„£dS³vÝFÍšÚ¿oâèáÇ ôàúÕU,0jx`Õj¹nbee.—Ÿ9y"ë×!j’ïN= »·pa÷†ÝÿyúT]Ýn=ûähsÿþÝ;ÍZ¶ÉvI³N½††ƒƒ¼¼}ë6h¼g÷É£Gž?{ºsë–}»wÖoÔXuÊ4ìSÁ¯fíºûwí°ï”ñ£ÕßÓ˜‡|wªW¿OÏž>5ä³~B33óé?/Èz³¨JpÐ!DóÖm³-742ª]¯þ‰#‡Ã==~ÒœéS~š4Nµªnƒ†ÃFË·àQãœ3}Ê´ ?¨ÞÚØÚÍþå·œwê9)J!„ŽH€" á€b(M.þ$2ÒÈÐÈÍÕÅ¥\ö "„ˆxüÄÉÉQª£wüìåjuëê¤ó¸¸Ø‡aa††üttt²®ÊÌÌ|þ*>ÎÇ×ÏÐ(—Û=‰‘J%vöït”œ2ÒÓŸ<Žxùò¥·êÃ~y{þ쩵m¶q5Éw§b¢£’““<Ê{°CM^ÅG=ybïèhmc[ð‚ŸÆD?Žwpt,çâVˆçÙ"«ýAÁBˆ6msùsC)#OK 9{¶IÝêÖVù·'B# Ä“§§ï9x²Bå@ÕChhRvBc|lÜë×Ú·l¨¯ùöoÄŸë%ž¾žž‰‰qB|œ¶ P\$ÄÅ™˜“"Ah”Nö6q/^h» ÅBfffÜËÎö6Ú.(%€ÒÀÝÅ)5%%>6VÛ…¼?ëV¯‹x¡í*€âèU\\jJŠ›‹“¶ J B# 4075q´·}üàAÙù¬þ«W òô|¾š(ƒ233?xàhoknj¢íZ€R‚Ð(%ý½ÓRRžEEi»Úô4**5%¥²Ÿ—¶ JB# ”016òòp}þ(%E¦íZhGJŠ,2ü‘·‡«©‰±¶kJB# ôðóñ071¹síz:÷meOFFú½7ÌŒü¼Ýµ] Pª¥‡ŽTZ·F%©DÜ U(2´]€÷G¡È¸{#TdfÖ«¨£££ír€R…Ð(U ôõë× LOM¹r%-5UÛåxÒRSo†\IOM©_3Ð@__Ûå¥ ¡PÚ˜›š4­_SWGz9¤L} P6ÅÇÆ†^ÑÕ‘4­_“'¦ï‚®¶  è4©[=äú;ׯ[ÙX»yzi»(E,U–ñà~ÜËX'‡j•+èrW*ðn¥“®ŽN­ªË»9_¹y÷úÅ V¶66Ž––R~­J8¥Bñ*>þåÓ˜¸/MLÕ©fkm©í¢€ÒŒÐ(Íl­-[4¨õ$úÙÈÈ{¡¡! MŒõõô¥º¥áÀèÈ'™éiÚ®%IRB‚âÞÍ›Ú.¤”r¹û¤Ÿž !lmlú÷é}áÒå‡ÂílmÝ\]­,-…Dâëã-„ÉR–üñGÕÀÀi“&ª¶ýuîÏ[¶Þ²mÛØQßGÇÄÄÆÆ}кuy!ÄgŸô×××740ÈYj“F ÍÍÍ:ܹC!D\\ܹ ~ú‰TšýïÔyÔ¦é8Ìž;ßÔÄD‘&O»výÆ«„7W—Þ=?~‹C›»³611Q½~þâùƒ‡lmlþZ¾TWWWàXmߵ듾}ztûHáêâ’žž>~ò”‚Œ[è³íl@Ñ"4 9s”¦µU+«£ÂÔÔÔßÏïÁÇÖÎÞwîÜV*•­[¶P·lܰAÖ G|û"âñ“FFEíØµ[!23sw?,,!!Ñ߯Âù‹—Ô ]]Ê]½)„prtt)Wnã?[âã_5iܰNÍš}{õ̵l}}ý¶­ZmÛ¹+)9ÙÔÄäÀá# …¢s‡ö9[¼6µ¤äd¥R¡z]«f ?_ß>½zšç±Iᘘ˜XX˜«ßÆ¿zîÂEÕ7|ä}¬n„ÞT(Í›4Q¯ªQ½zÇ-ª³E‹Ð€v˜™š !²-W=HÆ<Ë÷p¸¹ºfmàìäxóö-!Ä‹/…ÖVÖêUºººF†¯?wwáÒåÅË–_ 155-ïáîîævûîÝ\‹‰ŒŠBlݾc»*¼©‡vqBH¥ÒU,[½nýž}A{‚‚¤RijUÇåå陳«ŽíÛmþwë±'Ú·m| zµª®..9›¼6µi“&z•/Ÿw5yn¹, ï¾þJ}'ª"Y&ëÙï“¥¬èÕ£»T*ÍûXEEG !ll^ŸGû<þ@µÎ"< P„h‡ê>Ì«×oT ȺüöÝ»‰ÄÆÚF½$.>>kƒ—±±n.®B3ss!Ä­;·U«Â>L–ÉÔ-§Î˜©T*·n\¯ºDvêÌ™ýæZŒµµ•bôÈÝ?ìšk{;»ï‡}7ò»oïÝ;|ìØŸ«þþ~ìøm›6älY¥re7Wׇ׮Qóò•+“'ŒÏµÃ‚×V8ŸDUW&ÆÆ ë×[·qStLL9g缕………"*:¦œ³³jITt´¦Ûf«³Ï!„€vÔ­SGqäøñ¬‰"*:úÚõþþææ¯¯4^»v]Ý&11ñÒåòå=„îîBˆ‹—.«[ž;AýúÖí;áýz÷R¥2!Ä•kÿóYyyzJ¥Òã'O©—¤¥¥õýlàoK— !B®^íÔ­Ç“ÈH‰Dâëã=xÐÀömÛ„GDhÊBÚ·;uú̶; Z·h‘³ÁÕV@Ö––OŸ=U¿ ¹rå-;ÌÊÒÂB¡ú¾“¼•§‡‡"ë—s„\¹ZÀ:‹ö,@Q!4 ÎNŽŸ}Òÿü…‹#ÇŒ=~òÔÐÐÍÿnðÅàŒŒŒ‘þÍÚòöÝ»3žñøÉ£ððQãÆ§gd|>àS!„——Wýºu¶îØtà@BBâå+«×­ÓÓÓSmåíåimm|ðPdTTdTÔª5k×®ß „x«P(„6ÖÖr¹üȱãñ¯^ÙÚØôêÑýôÙ³;vïNLL<~òÔÐaÃoÞº­úl^•Ê•e2Ù/¿ý§P(®]¿qðÈ‘jU5ÝuÙ¡];yzú+ÿjÕ¢¹±±QÎùÖVC®^Û¹{OØÃ‡k7lÜR¤ßt¯z.Nl\¼"ïcX¹RíZ5—­øsÿÁƒ/cc?1{þüÖù6g!ëÙ,ÂÁí©hѰ¯‡ !þÙºíÐÑ£ª%åœÎû¹FµjY›uîÐáêµkªï`462úü³AI)éBˆÇ‘1ýû~÷ëèq„ºººÓ&Mü}érÕVzzzþþzÑâ%tî*„¨R¹ò¦µ«‡3fÂD++˺µk7oÚdçž=ß}?jÂcz|ôá°¯‡Êåò‰S¦MÓ„înn æÌ®èï'„J¥Ã¿ýzÚÌYMÛ´ÕÑÑQ(~¾¾šî;B8;9Ö¬^íÂ¥Ë]:vȵA¾µâ`~3dðÐÐ S¦J$’ÀJ•fNò͈‘…è'Wvv¶BˆþÝZ»f !DÇJ1sÊ”±“&;^¡««;jø°™?Ï-`…> ÙÎfQí8!$™y>¦ ¼k™™™áÑ1Oý|}¬¬¬²­ ¬UgЀO¿ýjÈk6‡\ 5·´ÔÕÓ϶}bbB¦2cú¸ff¦Ù6W*•×CC]˹¨>/§T*…G¸¹º¨¾=Bóô©­­ŽŽŽêíËØØ»÷îVP/TIHH {ð þÕ+''G_o?þZùÖVQÑ1R©ÄÑÁá-k+ˆ<Ž•âIddll\_©TZ£~ÃÁƒ~õ嬳Ðg!ÛÙÌiãŽýBˆžÛ¼éÎ(˸Ò€–I$wwÕóФ~ˆ¨ç¹ü­W"±´´ ¨31 !¤Ri•Ê•³¾õ,A¶Übcm]¯NîúÌÍͪU­’w‘o$ßÚ AýL ÷ c%„p)WÎ¥\9!Dzzzεy×Yè³ð~Ò2€²†Ï4P2x—w504Èu•R©¬RÑç=×(#k];u ¨è/„J¥þ>:¹ÝjhhàéVî½—†‘J¥];uô÷óÓv!PHÜž @±6eâõë@Ÿó!7²5ÐÑ‘V ¨ðöŸ0Ä;¢££“õ$@‰Ã0”î®Îæ¦ÆÙ*Ê@îM¼3„FJ ‰U*èèüÏßf¦&.Îïïé/€²†Ð@Ièï£P(…øï)ª::Òê•ý$Ú­ Pª(Iœìl¬,…ø/'*Ê@_í–(Ý”0U|Õ½±µ¶r°³Ön=€ÒÐ@ S%À7S©BH¥Òj•+h»@)Gh „±¶´pt°•‘©TúóÜTÀ»Eh ä©à—)„³£½•…¹¶k”r„FJžÊþÞ‰¤Z%îM¼s„FJ3S/w—J~ÞÚ.Púéj»”i …òé‹—OŸÇÆ'$Êd©é™™™Ú. J}ýÇÏi»Š·¥#•êêéš›šX[Z8ÙÛXYr·-;„FhGzFÆÝ°ð‡£22¦fæ&VöŽ:ºz¾ Ê…R©HO—%'?ŠŒ¹öÈÌÔ¤‚—»«³ƒ¶ë¼FhÄû–™™zç23ÓÑÅÕÎÑQO__ÛE´/911&2òÒµ[""«øûXZ˜i»"€„F¼géé範>çàììâá¡£Ë üÇÄÌÌËÏÏÉÅ%<,ìè™K}Ëûzºk»(¡ïQ²,åÌÅkò E@õj&¦üý cSSÿ*Ub"#oÞ KL’U«TA*å¹} M„F¼'ɲ”cg.éTÔÓ7Ðv9€bͱ\9C#£û·nÉåéukT–ð‘wÐþt‡÷!==ãÌÅkz†þUªaimíXùYlüõ[÷µ] ”i„F¼s™™™çCnÈ3¾•¤::Ú.G›Ö®ús`ßómÖ½C›ëV¿‡z ˜353÷ªà÷ "òaD¤¶k€²‹Ðˆw."2æyl¼O¥€7ºÆ8u˜±#¾}wUJk;['W··ÃRÓÒ´] ”Q„F¼[7ï>p,WŽ'ß ÇÅÝMÏ@ÿÆí0meÂÁ»u',\¡Ì,ç®ñ™éOcb‚÷î:qô°\.¯\¥Úo‡›˜þ2gÆk×”JŘaC'ÍüyδItèüâÙ³[ÿ™6g¾­­Ýέ[.œ;ý8"Â/  fízmÚuBü¶`nZjêȱÔÏ›ù“ݧƒ¾TddäºIÞÆÖ®S×èÈ'G›˜švú°{•êÕ—ü2?ôÆU{§_ ®X)PÕrû–Íû÷Žts+ß«ÿ€º ª;Ù²qÝÉãG…õ4ÊT*³öáì™­›7@ñ'‘J]Ê{Þ õòp±²0×v9Pæp¥ïB¡|ø8ÊÑÅEÓ÷1*22ÆŽüöÒÅóÝ{÷mҼ奋çfLž(„¬beemnaQ«n}]ÝÐk×6¬ýû÷_çÙ;8ê¬\¾dÕŸËœ?ýb°¡Ñ‚ÙÓíß'„°µ·?´çÅógªÎŸÆÄÚSÎÅE¡i“¼]¿²lÑ/û÷ì®Û áóçÏfN8|ÈOŸÅ´n×16öå?|¯P(„-_òÇâ…u4úvä.îî“Ç:r0XÕËý¹ôwOO¯ªÕknÙ°nçÖ-êÎýøÃHyº¼{¯¾†G?qäp^3#ãü™ÓÓ§Lxv¿ËGݽ¼}–,œÿï¦õB;Çc‡F>ŽPµŒŽ|røà~G'ç|wJ k[[S³á|²´€+x‡ž¾x™‘¡°stÔÔ "üQÔ“ÇmШ‰¢zÍÚkV­H—Ë[µmæäñYJ·ž}T-Æ­X³ÉÙÅEqãúÕî=ûô0HѼe›׮ܸvµE›šµhõ÷KÏœ<ѱëGBˆÓÇê4hÒ,MòÝ##ã%­‘J¥õ7:ðCÃy‹– !ÜË{þ4qì“'&Æ&[ÿÙØ÷Ó½úB4kÙ:-5åß6mÑ*öå‹íÿnþdЗ=û~"„èÑ«ÏÀ>gff !ÒRSW.ù­qóªËƒ>ìöÓc×®ú³Q³æy×ccc;uö<©TھˇÊŒõ«WuêÚ­QÓf‹wúäñî½ú !NŸ<®«§× Qã|÷J{'LjøÚFxÿø±‹wèéóXS3s=}}M ¬¬mttuW-_zâÈaYrR•ê5æ.\’kûzõ«£â×%+ú”.—?y~üð¡„„„ôŒt!„ƒ£SÅJ§OS5;qìH½ŒŒóØ$_µëÕWývâàà$„¨×°‘j¹«››âYLÌÕ+—Óåòµj«7©^«nlìËçÏž^8wV‘‘Ñ®cgÕrcÓ†M›©^ß¹}+6öe£&-’eɪÕkÕ ô 6öeÞõ4hÒLýÛR£¦-’“£££Ì-,«Õ¨uúÄqÕòÓ'Ž×¨U‡‘(e,ml Å‹¸WÚ.Ê®4âŠ{•hba‘GK+«É3æ¬þsùôÉã¥Ri¥ÀªÝzö©]¯~Ζnžžê×÷ïÞùgãÚ“G( W7Ý,÷¾6kÙjÉ¢_’“ÓRÓn…^ïÙwn¾›äÍÚÚFõB*•!Ëekð4*J__ß˧‚zI¹r.Bˆ—/žÇDGš[XªWÙØØª^DE>BüôãØl½%%&ªGÌ•s–œË•BÄÇŹ¹{4mÑjÞ¬Ÿbc_J$’[¡×¿ÿcwJ }ƒW¯ím¬´] ”-„F¼C))©ÖïMU©U§^­:õG<ºxöìþ½»'µøÏÕž^šÚ+•Ê©~°´¶™9‘w… ÆFÆß$ÕÚÆÍZ,Y¸àÜÙÓI ‰æ–5j×Éw“¼Iò» ÊÐÐ(===))ÑÂò¿_b" !¬mlŒŒÒRSc_¼°¶ý/+>|ðߣÿŒ…ÓæÌ÷(·¼£BžþúiRb¢ÂÎÞ^Q¿Qã_çé=yBH$ºzzu4*ÈÞ@Ébhl”œ’ªí* ÌáöT¼C隣ríJÈäq£”J¥«›G×=§Ìš«T*ïß½“Ç&¡×¯>{3ð˯«V362Vdd<)2UkÍ-,«×ª}úı“ÇŽ4mÑRGG'ßMÞ’«»Gffæõ+WÔKnß 5·°´³wps//„¸~õõªëWBþÛÊÍ]þð½ƒê_èµkKÎÏ7£†Ý½ýº·«Wtõô„Æ&¦µêÔ=}âØ™Çj׫¯º)JÝôô}¸P„x‡233ó¾ çíãsùâ…¿–/yùâyº\~ä@D"ñôñB˜ššEE>ŽŽ|¢ü߯©p÷(¯§¯áìét¹<òqÄŒÉÒRÓÒÒ^ÿá¹Y«6Ξ¹qíJ‹Vm ¸ÉÛ¨Q»Ž«›ÇÆu?‰Q*•Î9qäPçºK¥ÒÚõê—suÛ°vUÔ“'ééÿnZÿêU¼j«ò^Þ5jÕÞ³s{Dø£´ÔÔ½;·/Y´ÀÒÊ:çÓ6®ý{Ô·_©ßîÚ¾õÌÉr¹üÒ…óÿnÚÐå£êMš4ou%äRÈå‹Í[¶-’]€bG"É,š¿øÞ¡ÚdlbÚ»ÿ€7­ï×½ËGZ¯ý{å'ŸöôòB4nÞ2%%u@ïîÉÉIY71·°ìÙ÷“½;¶uiÛ|ðgý<<½>ÿêÛSÇŽ®X¼HÕ ~ƒÆB§r.¾þÜämH¥ÒñSJKMûäã®Û4›8zDëv{öé/„ÐÑÑ™8uFZjêg}ºwjÝôÈÁà>Ÿ|&‘ü—¢Gþ0ÑÎÎî‹þ½>l×ò·?×kÐðó!ßæìÿqDøõ«!ê·tè4oִέ›N?ºVݺý~¡^U·~C]]]3S³:¹}((I&²Ã;³mßÿŠÖövy7‹}ùâqD„D"qs÷°´zýxEFFBb‚••uÎMÒRSÃÖ/ï¥zÔjì‹ÆF&Æ&yŒRˆM N©T>~ž_ÞÓÛÔì[ªÈÈ »gddäâê®NŒjÑ‘Ož=}êìâbgï÷ ¯ztl;aêÌú?¸ÏÙÕ5ç=¨zukܬŀ/†¼ý@1tïæM#IíjÚ.¤Û¸c¿¢gç6Ú.@Iƒp }Ö6¶ÖÿÿXѬttusMŒBCCß þ¯{°Íeó‚lòÝ—smlni1möü|ûT“J¥îîå…{.«ttu³Ž›S9§r.H5–·o…œË/œ;óìiL»N]Þ¨7 o„F”i¿.ûSÛ%„„WÆ zñüy×î=UÏÅŠ ¡(ŒŒÇOžîW±b®«º÷ê[µZ7½h ä‹Ð” ºzzš5×´êƒßs=(#xz*@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#B#@#]mH|\웡¯âãåiiÚ®¥ä113³³·÷õ«¨o` íZ´ƒùó6˜?ÌŸ·Áü€R€ÐˆbíέЫÿ:¸oσ{wµ]K‰§««[«^ƒ¶»tïÓÏÂÒJÛå¼ÌŸ"ÄüÁÛ(ƒóJIff¦¶k@©µmßÿŠÖöv…ØöQØý)c¿Þ³ËÃË»]§®õ›4ó¨dmcËߪ !))1&*òú•£ÁAA»v(”Š!þÿjø(#ccm—ö®0Šó‡ùó6ŠvþÜ»yÓHGR»Z@‘×Yvlܱ_ѳsm $!4â*\hLKM?cêòß~ñôò?}v³Vm$É;ª° JJJ\³bÙ¯³¦›[ZN™³àƒN]´]Qcþ¼S̼·Ÿ?„Æ·GhP<ÅË‹çϺ·k¹zŲgü¼ÿÌ¥æ­Ûò[Ñ2552ìû×n7lÚüóÞÝfM_šþrÄüyט?x¥{þ@)ÆgQŒÜ¹úÉGttuw=åíë§írJ3;{‡ùKÿ¬×¨É¨¯¿|pÿî« Œ´]ÔÛbþ¼7̼R9 tãJ#Š‹¨'{vhãTÎe÷ÑÓüÆö~tïÓÓîàSÇŽ~;¨¿R©Ôv9o…ùóþ1ð6JÓü€RЈb!E&û¼ww Ë¿·ì°²¶Ñv9eHþÚ¼íÀ¾=?OýQÛµóG[˜?x¥cþ@Y@hD±ðýÐÏŸD„¯Ù¶ÛÜÂRÛµ”9µë7œ³h颹³öîØ¦íZ ‰ù£E̼R0 , 4BûΜ8º}óÆËVºº{h»–2ª{Ÿþ=ú~2ÿ™LÛµ¼1æÖ1ð6Jôü€2‚Ð-S(¿Þª]‡æm>Ðv-eÚ¸i3^½Z¼àgmòf˜?Åóo£„Î(;вíÿl¼wçÖ¤™sµ]HYgkgÿݘqK~™û*>NÛµ¼æO1ÁüÁÛ(¡óÊB#´lÍŠem;v.ïí£íB ú úRGª³eýZmò˜?Åóo£$Î(;ЦgOc.;Ó­W?m!„055kÛ±ó¾%æqÌŸb…ùƒ·Qâæ”)„FhÓ™ãǤ:: ›6×v!øO“Vm.œ=-OKÓv!Âü)n˜?x%kþ@™Bh„6ݺqÍÛ§‚‘±±¶ Á*W­–‘ž~ÿîmmR ÌŸâ†ùƒ·Q²æ”)„FhÓÓ˜hgmWלʹ!žÆÄh»aþ7̼’5 L!4B›RRdF&&Ú®¯™˜˜ !’µ]H0ŠæÞFÉš?P¦¡M™™™‰DÛUà5ÕéÈÌÌÔv!Âü)n˜?x%kþ@™BhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhhDhÄ{~h IDAThDh@Ñ‹Ÿ:c¦B¡Èuíªµë.‡\)D·a,^¶<×U'Nžùóܯ‡;qÒŸ«þŽ‹‹+DÿjÛwí>yútAZîÚ»ïøÉSoºÕ»¨xG(z ý¦¯¯¯££“ëÚÅË–Ÿ=¾Ý–÷ð:pðèñÙ–™0qè°áÇNœLMM½yûöoK—µîØùÔ™3…BeÉò?¶ïÚ]–+þZµmÇÎ7Ý*AÁfÍWˆJ€wDWÛ ´¹º{_ÐÞí[‹¼g©Túù€OgÍ[·v-CCCÕÂ]{öîÛÌZIš\>{î¼-Û¶«:´³µýqÜM5R½ý¨gï¦éèè¬ß´YÎûöê9zÄpMG( ®4 (Ý»&„pwuU/™2cfðÁCß ýjûæM“Æ[¶bejjªzíº›fϛ߶U« «W­ûk¥““ãÀÁCThâááöàAÌÓ§ª·­š7BŒ7þ翜8u:))Iabl\¹R€¥¥eG9qòÔ†M›»vêØ±]»lÃý¶dé‰S§¿2øpÐÞÍkW;ØÛOŸ=çù‹yTèèà°hþ<õ¿ŽíÛ !ê×­›o‡sgNoÛº•¾þ–õëÚ´j™­ÛÙsçíØ½gÚ¤‰'ܲ~—§çðÑ?„=x nðïö×nÜXõDz­×7mÜh톡·nåQ'P„F¥{aaBÕÛȨ¨=û‚úöê9 ?Ïò­Z4ŸðÃuc™,eÉT œ6ib€¿åJ¿ÎýY™™¹eÛ¶<†(ïî.„P?µU‹æÓ&M451Y³~ÃÐaöhÕ£O¿µ6ªïÏ,È(Ï_¼Xÿ÷_ãFªèï—m8==½Ï>éß§çǶ66~*ôïÓ;33óá£ð<*466jÒ¨¡êŸµUðƒµjT3rx¾º¹ºZYZ ‰Ä×ÇÛÂÜìÒ955uÑ’¥kÖoX³~Èo¿É{GM{táÒåÅË–_ 155-ïáîîævûîÝ‚Š´´´ïFŽ’ÉdË_¤NÑ…î0**ZQ) bÖ…öv¶7oÝV¿53}ýœU©T"„È,X¸ò@h@QÒ×ÓŠŽÉÌÌT=ËÔÊÊJqëÎmg'GUƒ°‡U€BX[[ !FÑýî9»ª[»ö¹ãGs.úô™ÂÓ³¼"þÕ«Æ-[÷éùñ˜‘#Ô ¸iË¿ª{>ó%_SgÌT*•[7®÷öòBœ:sfÿÁƒÙpâ”i·îÜùuîÏ^žžoß¡êH>|îRîõÓ}nÞºíôÿV¡~~¬ .¢èð™F%o/ÏÔÔÔ§Ïž©Þúùú !.^º¬npîüõk/OO©Tzüä)õ’´´´¾Ÿ ümé²<†ˆxòXáëí-„°´°°·³;züDÚÿ~ÿDBb¢\./ïá^èQTnݾѯw/UÀB\¹v=ß­„Ëÿ\tàÀwC¿jÒ¨a‘t¨Úßs^½È¨¨/_V ,Èæ@¡P”jT¯&„xþßsb*W ¨_·ÎÖ;ƒHHH¼reõºuzzzªµ¶66½zt?}öìŽÝ»Ÿ<5tØð›·n7oÒ$!?‰444T_pSÝ€úé /ŽŸ<ý(<|oÐþAC†µkۦУ¨x{yZ[[<µjÍÚµë7!^ÆÆ* M[;qâ÷eË]]\lmmwïÛ§þ§T*óíÐÆÚZ.—9v<þÕ«¬}Vô÷kP¯ÞÖí;=š’’òðÑ£ “§èëÒ·O¾{¼ nO@QªY­šŽŽÎ£ðˆºµk«–̘2eäcG› „ÐÕÕ6iâïK—«Ûûz¨\.Ÿ8eÚD1Máîæ¶`Îìœ0ÍêQx¸·—úi:íÚ¶I“Ëþ¾øëá¯ïP­\)`Ɣɪ뜅EEOOoØ×_/Z¼äƒÎ]…U*WÞ´võ°QcÆL˜hee©ÞÇlÎ]¸˜™™ùøÉ“ñ“&g]Þ¶U«|;lÞ´ÉÎ={¾û~”ê{³n>cʤ±“&õßg;ííìV,]log—ï^oC½Îxw¶í;âã_ÑÚ^ã²/û},„X¶fÓ{, ù(g¢³tõÆŽu×v!ùcþC̼|çϽ›7t$µ«¼ÏªJ™;ö !zvnó®=nBBRâÒ…¿ª—¨¾R".>®¢ŸŸ‘‘QÎM^ÆÆÞ½wßÈȰr@€ŽŽN§¦¦6ÿ ýˆo¿éÖµKÖåirù£ðð'‘‘F†Fn®.Y?øWˆQ²Q*•×CC]˹¨>©T*…G¸¹º¨ˆú¦òí0æéS;[Û\‹ŒŠŽ~øè‘³““»››:9ïWPľü|`>ýÂ#"ÜÝÜTK$‰gy!<4mbcm]¯NîWí²Ù¹g¯•¥eçí³-7ÐׯàãSÁÇ'm >J6R©´JåÊYßz–÷(D?ï0§¹:;99;9½ÍèÀá/(b^åËÒ§÷ò?Wæßô )Š•¯;j¤úS‘Þ5B#ŠÞ—ŸrvvNOO/Ún#£¢ûôü¸aýúEÛ-€ÜªåK>ý|0¡Q”¢¢’ÇŸîÝ¿€ßÔ}{…÷ï˶êŠ,¸Bw›š’"„042*êŠÞ‰¼çOJúÔJ–%úELLô„©3×oÝ5ç×ß7ï š>÷×Ô´´é“ÆÇÅÅípWjæOIŸ!jyüðÉC¶ú XaÖf…ûT²æ”)„Fh“¥µõË/4­•êèH¥R©Tjee]³vÝ_ÿáTÎåï?—§ÈR²6Sdd„Ý¿ûìiLÖ…²Yº<]‘œ”˜‘žþº±BqçöÍœ¿UWªRÕÓÛGS%¹¡–"K‰¤T*s]ûèaØÝ;·º÷êëåí{ä`pÖfšŠT­ºz=)1QSIy¦qßóÜ •¸Ø—B+kë¼Ç-&òž?y(éSëŸukâãâ¾9ºa“¦ª%ºzz5jÕî7`P\\ìуrv%KNJKMU¿U*•9g"êÉãÐëW“eÉš Î[©™?%}†¨äñÃG-öå‹K^åQ¿ºÂdYr¶¹¡šHéryÖfÙz¥ÈdÉIš¶ÊªdÍ(S¸=Úä]ÁÓêU™™™‰$߯†FF]>ê±dáü“Ç·jÛ^µpû–Í+—/V].p*ç2fü$¿€JBˆQß|vïŽâ£ö­¿1º}箲ÙüY?]>wN–"B8»¸|Ô£wûÎ]Uý èÕ­BÅŠã'OÏ9®¦!„érùüÙÓ>¨T*MÍÌ>ôeÎÍìÛ««§×¸yK™L¶rÙâkW.W­^Sµ*g‘Bˆ EÆÜéSŽ: P(„=z÷ûì˯r= ß÷¼÷"«Û7C…Þüò=ÅÁÍŸ<”¸©µw×W÷ÆÍZdë°Ó‡ÝœË¹ØÚÛåk@ï5kÕ5a’êmLTäg}zŒ;A½¿±/_ ûêóÛ¡7„ºzz¿ÚµûÇoz$Këü)q3D%>BˆÔ””ßæÏ9$„J¥þ•*õÝ/oßœõ«+œ3mòÅóg7mßkjf¦êäàþ}ógý4sþÂj5j©›eëáέЃû÷­Ý²ÃÚÆVµÕ¾];ÍŸ3{ÁoUª×ÈZpÉš?P¦p¥ÚT£vÄÄ„«—/°}ÅJ•…‘«Þnß²yé¢Mšµ\¸|å¯KVØ;8Œ6ôу0!ÄøÉÓš´h©¯¯¿dåšÆÍ[!V¯Xváì™þƒ¾\¿m÷ï+VÙÚÚý¶àçØ—ù\§Êc!Ä/sg?zxÀC–¯Þ0ìû±ëÿþ+ëÅ!„B¡8T»n=ss‹&Í[Šÿ½I,g‘BˆG?{ölÜ”é³, ¬²yýÕ¯_/,g·yïEV§Žöôñµ´*éÓù“‡4µ^Å{ùúäŒ::::uê7ðòö-Äعu‹™™ÙŒy —®Z[£Ve¿ýréÂù7í¤ÏŸ4CTòþá#„XºhÁáƒÁC‡}¿æŸí£ÆOŠ‰Šš5eR®õ«µjÓN‘‘qæÔqõ’c‡‚í«TûŸì—­‡­?P*•§ŽS78~ô­}åªÕ²írÉš?P¦¡Mþ•˹ºý{÷Weùÿqü:‡ ‚ì " Š{ï=0³Ü«a–M›šiÃÜ•VZ߯·,¦iš~ͽ÷65Pœ Ù¢€ ¿?N¿ë7^ÏGpîsÝ×ý9§Owç}îûÜ÷ŽrÿRÈÑÙY‘”t[‘•™õ늚·l=ýÝ÷š6mÞbîGŸh wlÝ$„p÷ôjØÐV¨Týü­­m„ÆÆ&ãŸ8bÌ8{{¿&MG¼°°0úÖ­R6Wú&âãîÝ=rÌø±=éÝȧgß~¯¾9³Ø gOŸJIIî4DáâêÖ´y‹ãGê>Û•,RamÓpþ¢%=zõiÛ¾ã”צ !"#"*TX±iK\”F£Ù±åAC)ç¿ÅU´Ja@­•/„pqqÓÿUÕ´Yó‹—¶ïØÉ§±ß[ïÍV«Õ×­®Ð u» ¨C´JßùÄÇÆìÙ¹}ȰŽíäìÒo`ÐØÇžŒ‹Ž‹}à~I«K÷ ¬­: }x/5%ä¯sQ«ÿõY¢Ø ­Ú¶spt:zh¿n­Ðà~ƒŠ­epýõ §§BI*•jüħW._6íÝÙ––eŽ×!¼¼¼…Q7"ï§§ûœþK7ÀÝÝãê•+\÷¹)¯ !âbbnEÝLˆÛ»s»B–²¹Ò7qéÂyFÓ«_ÝS]ºu/ö1hï®m–Mš%'ßBtìÔåÚåK'Žé70H¶Q? Ë¿¯áêæ.„¸_¡Â*=øÐÞÝQ×#Ç?õ´¬¶Ú¦¢ýS j-í/¾ò ò+÷Je:véª;timmãëß$æÿª•SÝîê­Òw>aa¦uëŽõ3nĘq¥¿ &¦¦}úܽc[Ff†•¥Õ±Ã‡ ‚z¸ôµÔju¿Aÿ-5%ÅÖÎîø‘Ã¦ÿ Áņ\ÿ@½Bh„Â&½0eù×_|½ôã³ç—982<\ѤYs!DB|¼b×¶-{´ŸÀþŸ»»ç×½¼ê§å¡çƒ-­x5jäáåVúæJß„öÒ¶¶ÿœIedllnf®{˜–vïÔ‰ãùyy“Æ,ºúþÝ;K E'T«UBˆÂ.+ôÚË98??ÿÃ÷ßôð#M[Èj«…*Ô?¥0 Örpt²°´HŒ‹+9maaáËþëæî¡û½œLn^ñkXÛ4,úÐÛÛçÔÉã¥ORTïêQŽObb¼ÂÛǧô2J8xÈöÍœ>q¬ÿÀÁ‡ìkÙ¦­›Çƒ_uQ‚Ú°võñ#‡†yôÐ_¿Æ~þEhÿ@ýAh„œß|wöâùïŸø´·OãÒïݽ]Ñ´Y ÂÖÞNñÒkÓ6¢<úbÉbF³lů>ý„gÿoŸÆjµút‘ƒ!¹99S_~þ—¿/¹nDصØè[£ÆNÐ~hB\¾Zfa¥oÂÏ¿‰"4䟓ÇBÎþëŠ{wmwqu:|T‡Nuÿ yt¸F£9ü ›"”_…^{yG„]ýtáÜ_›æÛ¤2×PQV9û§×ZO<ýlfVæÏß}Sô& ¹99¿­üÙØÄ¤ßÀA%·hÛÐ6))Q÷ðÒ…bBÏŸ×Ív/5%&:J{é—ò¨óýcpRæÎÇ? @q!äœn•U?~?mÊóùeÿG4hð³§OíÞ±ÕÔ̬WŸþeŽ×ôÐ…¿vlÛ"„(vnªA÷Ô„F(ÏØÄäû5¿_øëÜÛ¯ÿëëç[þøcýº?Ö¯[óËOsß}ë«Ï>iÒ,pêÛ±moï0lÔ˜sgNïݵ=ã~úŸ'OÌ~ûÍðkW»÷ì­`ggŸ—›{òØÑ´´{>}míìŽ:—·aíêM¿¯B¤$'kïlñ@¥o¢ió:uÞµ}ëáûî§§_<²ñ÷5Æ&&ÚuoDFD„]ë7pp±#‡]ºõ037×^æ¾X‘åÇ*ôÚËœš’üôØýü§¾ó~ùk¨=dýS ƒn-!DçnÝ=´cëæÙ3§Ø·ûÚÕËûwïœ9íÕ !ÁÏ<ÿ’—·OÉ-6 ¼t1tß®QQ7þX¿nG‰¯ßˆ ÿrÉâ‘7"#>ýp‘±ñ˜ O”çͬ“ýcÐRžŸ@×=÷lßvìÐÁ¤Û‰[6nعmK÷^½µÇ®Kß/õ’——÷Ûª=ûô“–,9C߃ ׬ø±]‡ŽŽÿÜÆÐûê NOE­Ð4°ÅW?­š<~¤·Oã7Þž¥]¸ò§åÚ?Ì-,Z¶jóÜ”Wƒ† 555Õ­5ù…—sss—.ú`©B/ï9 7ùÿ{|uëÕ{ïîógÍÔÞjlò‹¯¬X¾ìé £…-Z}ýÊï¿»xᜆv¶í:t’Vú&fÌš÷áÜY‹æÏBO÷ýU?þ]óž]Û…ýƒ*6¡¹…EçnÝ<uóz#ßbE–ÿ«Øk—NOO{fÜÈü¼¼•·êy-=°JaЭ¥õÆŒw=¼¼ûõÝ1æ/ZÒ¥{nîéç^ »ryÉ¢…*•ªYó–o¿?oî»oðØÄ§O8>eòD!„µµÍ‡Ÿ~îæîQæ;Y÷úG¨„wH9w>3gÍýäÃùÌý; wíÑsêŒ÷X±y\\][µi{!$8hÈPYñ%gprviݶÝù࿆<òÏ ·•êŸB¡ß}Y• *y  ªlÝ{ÄÛÏߩܿŒZ¹|ÙûÓ_ŸðÔ3}þuÑã*¥KII¾innÖ4°…‘‘Q±g“n'Ú;8j—k4škW.¹¹{ÚÚÙiÆDßòðð42.ãÛ“R6QXX}+ê^jJ“€fæ¥ý¨E‹¬ò¿öŽŽºùô˜áÉÉw×nÛ]®?Q¹þ)Eío­¼Üܘè[)))^ÞÞNÎ.e¾¢Ä„µZUÊÈ„ø¸ŒŒû>ýÊÓôOíï2¥ÝK‹‰qvuµwp,¥þÊ)6×K?>yôȯ6k_uåúçÚ… ­ÌÛ·jV骰vón!Ä„áÅ/` ¥ 4¢í9rÚÎÉÙ£Q£ò¯²wǶWžy¢m‡NK¿ýÁ«‘Oµ•q`÷Îi/Nvqs_±~“»§WÙ+ú§ÆÐ?¨Ì¬Ì'G>zì¤g_zôOè¹³^.ŽÍ|«­ÒºÐ øM#ªQCk«ŒûéZeÐÃlÚw$1>¾o‡–Ÿ.œ›•™Yö:¨ á“Æ ›8ꑞýúÿ±÷pùÄ/èŸAÿ ¢æÏš9í¥ç…£ÆMЧ4Ù™6Ö ª­RÀƒQœìíÒRS ‹\à±<š·j³ÿÏ·ç,üñ›¯ºú~øþ;!çÎpH\Y™™{¶o}áÉq};¶Š¹ukîýÿýyµUƒºöñ‹þ©&ô*ÍHmÔ«o¿§žyvú”çôéŸ{©©…B8ÙÛVSNOE5ÊÊÎÙ}褳@{g§²G—p'éöÊåËÖ®ü96ú–µµM@óöfææe¯‰»Ÿ–¦)(èØµûÄç^6zœqY?¦2tôOU¡è}TaÿD\¾$òózwm_åEÖ+œž  ¨^§Î…¦gå¶hßNŸI.‡ž?÷çéð«—磻dçdWUmUËÄÔ,/7Gé*¬Ak'g—æ­Útëݧ<—K©cè=Ñ?ô>ªª²3³BÏžiß:Ð˽Þ5aÕ"4¨B#ªWÚýŒÇÎø6mêèR—ÿ7Ÿ““spÿÁ‡.~{ <è裞ôOXhh~n΀žTÜrC?„F•ÀoQ½lX5ör¾~½  _éZªQdÄõ›7¢²³kéQÔrôôQú'599%9¹m‹#(‚ЈjÐX¥W®ÖáÃÚa×Â4Mdx¤Ò…À Ñ?ÐGììëW¯yº¹8r P¡ÕÎÔĤ[‡Öé©©Ñׯ+]KµÈÌȈ‹·j`¡t-0<ôôQçû§   ìâE Óv­š*] Ô_„FÔ»†Öí[5‰‰¹ySéZª^xX„‰‰i¯^=ãcãÒÒ*v_J€þ>êvÿäçç] ÕäçuïÐÚØÈHér þ"4¢†xº¹´kÙ4þÖ­ˆ+W4¼sc-áçïëãëcfnQgÏC5¡ :Ü?YY™—þ ÎÏÍéѹ­…¹™Òå@½FhDÍññrïÞ©M<pX IDATZJòåààô{÷”.§j¤Ý»wûöí&M›¨Õj?ßð°ºy†ª ý}ÔÕþ),,LŒ½lifÚ¯{›VJWõ¡5ÊÉÁ®_÷ŽV榗CB"¯\ÉÎÌRº"}]»niiéîá.„hà'))99Yé¢`0èè£îõOaaajròÅsçn]¿îëåѳK[3SS¥‹c¥ @½ceiÑ£S›øÛw.\‰8æOÛ†vŽ llÌ-,ŒŒ îrê‘á‘MüÕ*•ÂÍÝݪAƒˆðÈÎ]ì•® †þ>êFÿh4šü¼¼ÌŒÌ´Ô””;w²³²Üœ{vleei¡ti€¿¡ 7gGW'‡Ä¤»Ñq‰q·¢òò ò.Ž™YÙÉÉÉÎö O>¬]ÒÀÂ,4ä‚&;ÓÀ²/”@ÿ@u¯¬¬,½\yºZs>*Ô2„F(F¥R¹:;º:; !2³²32³òòò …!ÝËñäÙ 6Ö úöè¨ûˆÖØÛ}ö}¾^îNŽvJVC@ÿ@u¦Ôjµ©‰±u+S¥k<¡µ‚¥…¹¥…¹ÒUTL¡7£ãÚ·jæéê¬[èáê|èÄÙ¸ÛIm[rK1”†þ>è@MâB8@%EEÇ¥¦¥·lRlyëÀ&¡WÂ5…†tÈ5þ>è@M"4•táJ¸«³£³cñkN´iÑ4ý~æÍ[±ŠTCAÿ@ô &qz*PÍÅ«Ÿ}·JQXX¨ÑhŒŒŒ´ÏªTª WÂ}y*Z#j/úú 5ŒÐTFVVvÏÎít’î^¸Ô§›n‰…¹™uÁ0Ð?Ðý¨a„F 2XYöîÚ^÷0ôjÄ…ËaE—¥  úPÃøM#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤@u‰‹[½v]Dddy¯]¿áðÑ£UµéM[·;qBÿyŽ?±ô‹/:¬ÿT¨(úú UˆÐT—ˆÈë/ý,äBhyÿ¼rÕî½û«jÓß~¿|ÓÖmÚ¿wíÙ»xÉÒJLrþ•©Óþ÷ǦÛIIUUÊþ>è@2VºUoáœÙÖÖÖÚ¿ƒÏŸß¸yË;oM¯è$ÁçÏ !6ü¶ÚÝÍ­ŠëCíFÿ@ôÔ=ijÈýŒŒœœ!DFfæÅK—ÒÒÒ8,#3ó|hèŸÍÏÏ¿ŸPríÌiié·¢c„Ú·kÐDûTnnž"===//O·Jjjê¹àà””Yµ™YYBFóÀ­è¦:}æÌ•«×rss‹½Þììl!DffVXxDaa¡nùÅK—îgdÈ6¢tËéŸJ tË騎45dô„Çûöîedd´fÝïÚAO>6aæ›ÓtòóógÍ·c÷ž‚‚!ÄäIOM}õݳ«×®ûÏ×ÿÕ~lòòô\´`~ëV-µO=>é™vmÛØÛÙ­ZóÛ˜‘#ÞžþæÃ#FµjÙbÉ¢žyᥫ׮ !zôøþ;o=ê¯àÿ~÷ݹàm #}dÖ;o›™š«vòKS‚CÎ !º÷íÿìÓ“Þxåå’[ÉÉÍýxÉÒ lÒ®âäè8ç½wúôê¥{½Û·³°°Ø´u[nn®“£ãâž4ÈÌÔtÚՃ Ôh4o¾óNa¡øcÝo;6mœúê+[¶ïرkwÉR?˜;gÜèQBˆ kV?1aü·òñ’¥›·m_8wö±ýû6¬Yíçë;mæ;‘ׯë&Ùµwßͨ¨/–|2ïýY¹¹¹/½öú¾ƒ‡æ½?ë—¾oÑ<ð“Ï>ÏÌ̪²7· è}Ð?ôTGšceeõý×_[ZZ!^~ñ…CGކ…G´ Ô>Û°aï?ÿLû¬µÍø‰O] oÖ´iffÖ·Ë—·mÝzáÜÙÚ‘_,ù´÷À  üñ´K’îÜÙ¼~]cŸb[ôöò²³µ*U@!Dl\\rrÊ  íÈÉ“ž255573+Yª—§§ƒ½½¢‰¿ŸJ¥*¹•ظ¸›·Œ1|ø#!ll¬ÿóé'= ü>ùèíxkë_þ™¹¹¹âä©Ó»÷íûhÁ¼–Í› !7ö½9óÂ##Ú´jU%ïm}@ÿÐ?ú è¨4B#PsZµh®ýL&„ðöôB$ùP³€&ºg=ÜÝ…ñññBˆˆÈÈ´´ôÀfMÿ<{N7ØËÓ#ôÒeÝÃ%?±•äæêêéá±vý†ÔÔ{}z÷ìÒ±ã“M(ýE·rñòeFÓ©Cݳ––þ~~E¾éo ýÄ&„pttP©TÍþ~èà „¸{7¹ü[ýCÿèƒþ¡ Ò@ͱn`­û[­V !t—gBØÛÙ?ðÙØ¸8!ÄÆM›uW±×òöôÔýíêâRžÔjõŠåß­\½fûÎ]ÛwíR«ÕÚµ}oæ ?_ßò¬^t+qqñBˆ–-šàìäxùÊUÝC;[»b[76fŸSyôý£ú‡þ€Jc ÔÝyVâß×J>[”½½bæô7ÇŽ© ÎNNoM}cú¯‡GD8|øÇ¿¼õî¬?ÖýVÑyììì„7nFyzxè^¾rÕÍÍU÷PöŠ„%^=ÊFÿèÐ?•@ÿèÐ?PQ\¨íü|}Õjõ‘cÇuKrrržœüì×˾«èTÁçÏ3.&6V¥R4ñé¹g‡>48êÖ-í• +$Àß_qúÌݒظ¸;wï¶mݺ¢S¡ZÑ?Ðý„F östpxlÜØ§NmÞ¶-==ýȱã¯LvùÊÕþ}ú”gu{ûÜÜ܃‡¤Þ»×¦U«ÌÌÌÿ|ýßä䔂‚‚ ¡÷<Ø®MkµºÂ»‚æÍztë¶qÓæý‡eeeݸyóýy ÌLM'=ùDÅ_"ªý}Ð?Áé©€A˜úê+¹¹¹³ç/œ- !y{þÉÇÍ›•gÝþ}ûlÙ¾ý·fhï“6íõW.ZÜwðCFFFÍæ½?«rU}4î»sçM›ñ¶ö¡³“Ó˾qvrªÜl¨>ôôAÿT%Ø ¢B¯F¬Û¼ûƒ·_){¨î&'‡…GXX˜·jÑÂÈȨBë&$&:9:j×JKK¼~=õÞ=77×ÿJ|Í_T\|ü›7ÝÝÜy{ë9U½EÿÐ?ú èŸ Y»y·bÂðÁJÀp¤0ööݺt®ÜºE/ôôAÿ@}Æ×r)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤Œ•.BˆŒÌ¬Ì¬ìܼ|! •®¥2’Sï !bn+] ý}zÿ¨ÕjScëV¦&&J×x0B#SXX˜t7&.1ñNr^^¾Òåè%ù^šâÏàKJƒDÿ@u¦¬¬,ݼ=ÝlX)] à_PFüí;®Ddff5´³u÷nÔÀÆÆÜÂÂÈØX¥R)]ZeD„GÞˆŠéÒ§Ò…À Ñ?Ї¡÷O¡F“———™‘™–šp'üF´›³c«@+K ¥KüÐˆš–‘™r)ìödGg瀖­Ì,øXõ—J­653353³µ·óöõMMN޾~}ßÑ?ý}¼š5ñ1RsñP¡5*énÊéà‹¦fæÍÛ¶µnØPérµ‹­½}C;»Ûqq×£nÞINíÚ¡¥™©©ÒE@}Çx¨97£ãNœ9ocgß¼];#àT*•‹‡Góví2srž8—v?C銠¾#4¢†ÄÄ'_¼ææíí¨æt#@©,,,[´ogljvüϬì¥Ë€zÏî¨ )÷Òÿ ½ææéééãSÝÛúuÅÏ>9¾Ìac¼võÊê.PiÆÆ&M[µR›œ8w!¿ @ér þ"4¢Úåæåé¹'<9I1î±'ž}b|aa¡"';û§o¿îÝ€öðà°Qc>˜óî¯+~ìÕ¯éõ888.øx©Z­:bT¾&ÍÊÃFŽéÕ·ß7_,=qìÈØÇžBœ8vĨĤG¯ÞUñ>Ê`niaçèxãV¬—»ô[H@5áH#ªQ|ⵑ‘ƒCéÃ:w뮽 ‡‹‹›¢[Ï^Úå^ÞÞBˆÛ çCþÊËÍíЩ³n•öº&'ßMºxæô©‚üü‡®]niÕ gß~Ú¿¯]½’œ|·WŸ™ÚÚwêuózròÝÒëéѧŸî¦ ½úȸŸgÓж]‡N'ŽÑ.?qôH‡N]¬XWäýTžƒ‹KrjZNN®Ò…@½Ã‘FT£¤äÛ†ª²îÊhoÿwªT«UBWwbãâLMMýš4Õ-ñððBܽ“”gfnnÓÐV÷”ƒƒ£ö¸Ø!ÄsÞ-6ÛýôtÝȽHîBˆÔ”ïF>} Zºøƒää»*•êʥзfÍ)ýuªPC[[•IÉ©žnÎJ×õ ¡Õè^ú};§²Ï#*3Uš›[äååÝ¿ŸÞÐÖN»äVÔM!„½ƒ£……ENvvò;öŽgÅ×#µXZZ !~ò™OãÝê£ôÄ(„ÈÍËÓý}?=]áäì,„èÞ«÷KMN;*T*c“®=z•ùÒUEmddne™–~_ fqz*ªQvv®©™™þóx5ò),, Ñ-¹zå’MC['gïF…¡çÿy*4$øïµ¼ !¢n\wrvÑþséÂ…o¿ü¬ÌŒvõŸÙ·›˜¸¸º !,­têÒõÄÑÃ'îÜ­»¥…¥þ/ P~¦¦¦Ùœž 5ŽÐˆjTPP Vé?O‡Î]¼¼}Ö®þ%1!A£Ñœ9}òèÁýÃGU«Õ»u÷ðòþí×q11ùyyÿ[·æÞ½¿oäÕØÏ¿C§ÎÛ·lºu3';{Ç–Mß~õ¹­½ºDh\ûë/3^Y÷pë¦'ÍÍÍ=wæÏÿ­ûmÄèqºUúô|.ø¯³ý>¤ÿëTˆÚÈ(¿ @é* ÞáôTT¯*¹–Z­žµàƒç¼?iüHSÓ¼ÜÜGFŒžðÄSB##£Ù >š?ëíÉOŒU«Õ¾þMž˜4yÿÞÝÚ§¿3{ñÂ9/<õ˜‘‘QaaaСÏOy½äüÑ·¢BÏëydØÒÅ 3îß715íÖ³×SϾ {ªk÷žÆÆÆæ]ºu¯ŠW¨•(Tº¨PØ–½‡u[Z5Øuø¤îa#_ÝCŸÆ~ßý²:::*-5µ±¯ë.[êãë÷굑‘áž^T*Õã“&kŸ²wtüä‹oâccn'&º{z:9ÿóËõÛvëþžñÞœïýsU›6í:NyýÍëáî^^ÅÎA537·³³ïÝo€‘1ÿí ^àƒ/ †Z­nÔ¨±hô€§ŒŒšJïéæáéæáYÑmù4-¹üÌé“·6¢B³†‹Ð”KZÚ½©Sž»“”4rìíuq€ú€ÐüÃÒÂrÖ¼›5oþÀ§Æ>ödÛv*zÐ0h„FàÆ&&½úõ—=5ä‘á5\ 8n¹"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)c¥ Êv9ôü¹?O‡]¹t/%%'7GérÀÔÌÂÚÆöʼnã•.äÁ4°vrviÞªM·Þ}œœ]”.§¦Ñ?z¢è}Ôóþ€ºAUXX¨t ¨³þØy°I`s{g§Ê­~'éöÊåËÖ®ü96ú–µµMÓ-íìíÍÌÍ«¶Èú #==>.."쪦  C—nŸ{qø˜ñÆÆuü;#ú§ªÐ?ô>ª¶Â/_¶0Run×¢j‹¬WÖnÞ-„˜0|°Ò…0$uüÿú0Pùyy?}ûõg‹š™™ŸøôБ£[·ë R©”®Ë°eef;t`Ão«Þ|éÙo>ûôƒ¥ÿéÖ«¯ÒEU ú§:Ð?J×eØêOÿ@Ä‘FT£Êi¼z~ÊSÇDG½ôÆôW§¿maiYMåÕ[7"Âç½3}ßÎí#ÆMøä«ï¬4Pº¢ªDÿT7úúг8Ò¨?Ž4¨.„ƒÚeïŽm#övqs;tîâŒÙóùÄVû7ùeÖU·;x`ä >q1ÑJWTeèŸ@ÿ@u¸ #4¢Y¹|Ù³F;aÍæ^|”.§Žë?xȶÃ' òó‡öévíÊ%¥Ë©ôOM¢ º×?P·Q[ìݱmÖ›¯MŸ5÷“¯¿361QºœzÁ«‘ϦG}|ý&v'é¶Òåè…þ©yôôQ—úêêFÿ@}@h„òòóò^x|\ëö>þò[¥k©líìW¬ßt#2â?‹?Pº–Ê ”Eÿ@†Þ?PO¡¼¿ý*&:jé·?pV˜RüšÍœ³à»¯>¿¦t-Fÿ(Žþ> º ž 4Baw’n¾èƒ—ޘΕ'”5ñ¹ýü¼7CéB*†þ©%èèÃ@ûêB#öË÷ßš™™½:ým¥ ©ïŒg}°xïŽm†u%Cú§–  í¨?PRaaáºU+ÆO|šû¡Õý‚jäë·nå ¥ )/ú§V¡ ƒë¨WPÒ•‹b£o 9ZéB „*•jèðQ{wnSºò¢júú0¸þ€z…Ð%;}ÊÚÚ¦u»J‚¿uïÓïzxXjJ²Ò…” ýSÛÐ?ЇaõÔ+„F()üÚ•&ÍU*•Ò…àoÍš·BD\»ªt!åBÿÔ6ôôaXýõ ¡JJ¹{×ÑÉIé*ðG'!DòÝ»JR.ôOmCÿ@†Õ?P¯¡¤Ü¼\S33¥«À?´ÿ:r²³•.¤\èŸÚ†þ> « ^!4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)c¥ (—B!Î^¸|ýVŒÒ…°[± Þ®JWÀÀp¤ ¾ðöpõrwQº †#À0¨„èØºyçv-”.êŽ4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ÊXé…eÜOß»k§™¹ÙG†—>rëÿsvqéÒ½çŸÝ·g—µMçnÝ«¡F¤ÜÜÜ£÷‡‡]ŽºikïÐØÏȣí,­tÊß3kW¯lݦmó–­«³^( üûŸRìÙ¹ÝÎÞ¾S—nU2 €’8Ò%©TªÂÂBek8°wϲ¯>ÿâÓÅ7¯G{êðþ}ß~ù™îáúß~=r`¿lžu«~Ù½}Ky¶XlÚZEû¯C¥R)]H¹Ô†þ‘‰™6åùO?Zpä༼ü“GÿðÍWÏ?1þâùݘò÷̊�\mÅVú§¢JÙÿ”¢Ø>äןسs[™kV;÷B†Õ?P¯¡$+«YÊÖ°wçvWW!ľÝ;‹=uéâùÛÊõ™¾BªiÚ*qÿ~º¢Ò…”KmèŸÊÈ̘öÊ ñï/X´fãÖO¾øïï[w}¸ä‹ìœœçÎJIIVºÀêBÿTT)ûŸRÛ‡Lçýñ?UæZE‡ÕνaõÔ+„F(ÉÙÅ5.&FÁnÞˆ »veìcOúùÜ·G£ÑèžÊÌÊÌËÍBdÜOÏÏË+ºVfVæ•K¡÷ÓÓKŸ¼ ??2"ìvbB±u8í×¼„¸X!„³‹‹²e”“âý#³~õªÔ””×§ÏìÙ§¯v‰±‰I‡N'>ó\JJò¡}{K®’™q?';[÷P£Ñ”lòá¯<;é©q#Ÿy|ìÕKK™V6¸æ…†›˜ø4Sª€ Q¼dvlÝìéÕ¨w¿Å–5fþ¢%­Ûµ+¹Ê3ûrÉǺ‡ q±£‡Ü¿G·$ùî©/??ù‰qÓ_}iü°!¬_WMÅëƒþ©Rö?Bˆì¬¬%Î|Ô£ï¾ùú„áOí¥Èˆ0ñ }È3Y´`¶â“…óÆ=úPÑï³öíÞ9zhÐÅÐóE‡›aÙŸydpòÝ;ºµvnÝý~ùýO_|ûƒ³‹ËÌ©¯h³TrÚR×¼CIÒ'ÉIDAT{vuêÚÝÔÌL‘­W”²ý#“žž–v/Õ/ IÉŸfuéÞÃÏ? ÓnÙ¸ÁÚÚú£¥_.[ñk‡N]¾ûú?çÎüYõV%ú§üJßÿ!–}õù}{^™úÖªõ›fÌš›·xþ\!Ù5i üpA~þÉãGtKïßãìâÚ¦]‡¢ÃŠÍ0 hˆF£9~ä°nÀ‘Cûœ[µ}À·ÕʰúêB#”äìâÚ¡K·ÿ­ýU‘­Ÿ=}*%%¹Ð!„‹«[Óæ-Ž9¨;EÐÝÓ«aC[¡R5öó·¶þû76Ö6 ç/ZÒ£WŸ¶í;Nymš"2"¢Ø´Y™Y¿®ø¡yËÖÓß}? i`Óæ-æ~ô‰¦°pÇÖM%§-}p »?}÷¶-C†¬ùMW޲ý#“/„pqq«Úi›6k¾`ñÒö;ù4ö{ë½ÙjµzãºÕU» =Ñ?Rúþ'>6fÏÎíC†xtäh'g—~ƒÆ>öd\lt|\ìwMZ]º÷h`m}ôÐíÃ{©)!0xˆZý¯ÿ×›¡UÛvŽNGí×­Üo`P±µª›ÁõÔ+„F(lâs/îÚºùFDxÍozï®m–Mš%'ßMN¾Û±S—¬Ì¬ÇŽ”²ŠŸ¿¿…¥…öoW7w!Äí„øbc¢nDÞOO÷8ü—öŸð«WÝÝ=®^¹Rr ®n«~ø®@S0æñ'k~Ó•¦`ÿÈØÙÛ !ò ò«vÚŽ]ºê]Z[Ûøú7‰‰Ž®ÚMè‰þ©Ò÷?aa¦uëŽõ3nûcnî¥ÌibjÚ§ÿÀ¿ÎžÑþ²ñØáCA=\z%jµºßÀ ‹Χ¦¤!Ž9¬Ñhú¬ï+¬ Cì¨?¸O#6bì„eÿY:ïé¿l¨ÑKù¥¥Ý;uâx~^Þ¤ñÿúb{ÿîýÉÖ²µµ×ý­V«Äÿ_#¾¨„øx!Ä®m[öìÜ^t¹»»gÉ +4¸Z%ÝNüâã¦L}«¡­] oZJõO),,-ãâJ>UXXøã²ÿº¹{ ^Æá”ܼâ× ±¶iXô¡··Ï©“Çõ,µ Ñ?Ræþ'11^áíãSÑ™²}ó§Oë?pðáûZ¶iëæQöÎd@ÐCÖ®>~äÐÐá#:àãë×ØÏ¿¢›Ö‡öÔ„F(ÌÈÈhÁ§ŸyhÀÝ;ûRcÛ=¸wO~^Þ«ÓÞróðÒ-\¿fÕ_gÿLN¾koïðÀµÊs1[{;!ÄK¯M{x؈ª\­ÍyϦa×§ÍP¶ŒŠRªJçíÝ8øì™ôô´bg†üuvÃÚÕOš\æ ñ±Å3gB|lчa×®joÕPKÐ?RæþGûA|\œ.¼edfÄÜŠòön¬;Ùá[´r÷ô:vè@Û¶.^™:ó½òÔÓØÏßÇ×ï衽úö;|èñâ*Ã@ûêNO…òºõê;bÜ„i/NŽŽºYcÝ»k»‹«ëÐá£:tê¬ûgÈ£Ã5ÍáÝ¡ü¼}«ÕêÓEŽåæäL}ùù_~ü^ÏÁÕgýê•¿ÿú˼?³°´¬ÉíV Eú§tO<ýlfVæÏß}Sô& ¹99¿­üÙØÄ¤ßÀA%W±mh›””¨{xéBH±¡çÏëf»—šÕ¼e«j¨½2蟊*sÿã „¸rN·Êª¿Ÿ6åùü‚âwa)iÐà!gOŸÚ½c«©™Y¯>ýËYÒ€ ‡.„üµcÛ!D Ÿ›jÐýõ¡µÂ’ÿ.÷ôn4qä#i÷Rk`s7"#"®õ8¸Ø‘Ã.Ýz˜™›ïÛ³KûÐÎÎ>/7÷ä±£Eo’V&{{‡a£Æœ;szï®í÷Óÿ%Wixébè¾];¢¢nü±~ÝŽ7^¿þå’Å7"#nDF|úá#cã1ž¨‰Sú§¢Ê³ÿñóèÚ£çžíÛŽ:˜t;qËÆ ;·méÞ«·öØu黦þACòòò~[µ¢gŸ~²Ã’%gè;`Paaáš?¶ëÐÑÁÑ©Š_³\訨F*•ªPÿÉßYXZ._³þÞ½ÔIc†§$ß­îÂöìÚ.„èôP±åæ»u ¿uóº¢[¯ÞnógÍ6eòÄ)“'^»rùÃO?/ýš(5ƒþ©„rîfΚۺ}ûæ¾7qìˆo¾XÚ®CÇ©3þ>×´ô]“‹«k«6msssƒ† •ÕPr'g—ÖmÛåææy¤æN•¯Lÿ–ãW€*¦*y ªlÛwÌÓÇÇÙݽœã¯]¹4iô0#cã_6l®=÷wNºhïàhddTÑSR’oDFš››5 lQrõbÓ–>¸š¬_½rÆ«/=üÈ—?¬4·(í‡R¡öO^nnLô­””/oo'g—2Ç'&$¨ÕªRF&ÄÇedÜ÷iìWcMR ú§¤ÝK‹‰qvuµwp,öT¥wM²¾\úñÉ£G~ݰÙȸ&®wP¹þ¹zá¼½µUÛ•¹Ù) Ò¨FOœ5·nØÈϯü«ÜIº=yü¨ð«WfÎY0ñ¹kä³K=”t;qÑœ÷~ÿõ—W§¿ýö¼Êsƒ@ÿÔ ú§îÉÌÊ|rôðá£ÇNzö…êÞ–>ýrêT“ÆžM{W_y€’ŒæÍ›§t ¨³Rï¥ßK»ïäVk€º'óþý¨ÈÈô{÷š4ðm¤t9B#j\aaá­Ø„K×®k ]==]]MMM•.  ¼ûé鉱±woß¶³µiØÄ¶!ç¤@­@h„2òòóÃ"£nFÇçåç7°¶¶²±±°°026Q©”® Pƒ4M~n^ffFZjjNv¶µµUSßF^î.J×ø¡J*(Ð$Þ¹{;)9%-=#3;??Ÿ†€zÅH­661¶i`eoÛÐÍÅÁ®¡ÒŠ#4¤¸@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4¤)B#@ŠÐ"4þ¯ý:äo=ÁeÑ’F–4°¤€%,i`I#KXÒÀ’F–4°¤€%,i`I#KXÒÀ’F–4°*ÃÖ¸9\§IEND®B`‚stravalib-1.3.0/docs/index.md000066400000000000000000000054171442076457100161040ustar00rootroot00000000000000# Welcome to the Stravalib Documentation! ::::{grid} 2 :reverse: :::{grid-item} :columns: 12 :class: sd-fs-3 stravalib is a open source Python package that makes it easier for you to authenticate with the Strava v3 REST API, and access your STRAVA data using the Python programming language. ```{only} html ![GitHub release (latest by date)](https://img.shields.io/github/v/release/stravalib/stravalib?color=purple&display_name=tag&style=plastic) [![](https://img.shields.io/github/stars/stravalib/stravalib?style=social)](https://github.com/pyopensci/contributing-guide) [![DOI](https://zenodo.org/badge/8828908.svg)](https://zenodo.org/badge/latestdoi/8828908) ``` ::: :::: ::::{grid} 1 1 1 2 :class-container: text-center :gutter: 3 :::{grid-item-card} :link: get-started/index :link-type: doc ✨ **Get Started Using Stravalib** ✨ ^^^ New to Stravalib? This section is for you! ::: :::{grid-item-card} :link: contributing/how-to-contribute :link-type: doc ✨ **Want to contribute?** ✨ ^^^ We welcome contributions of all kinds to stravalib. Learn more about the many ways that you can contribute. ::: :::{grid-item-card} :link: reference :link-type: doc ✨ **Package Code (API) Documentation** ✨ ^^^ Documentation for every method and class available to you in the stravalib package. ::: :::: ## About the stravalib Python package **stravalib** is a Python library for interacting with [version 3](https://developers.strava.com/docs/reference/) of the [Strava](https://www.strava.com) API. Our goal is to expose the entire user-facing Strava V3 API. The **stravalib** Python package provides easy-to-use tools for accessing and downloading Strava data from the Strava V3 web service. Stravalib provides a `Client` class that supports: * Authenticating with stravalib * Accessing and downloading strava activity, club and profile data * Making changes to account activities It also provides support for working with date/time/temporal attributes and quantities through the [Python Pint library](https://pypi.org/project/Pint/). ## Why use stravalib? There are numerous reasons to use stravalib in your workflows: * Stravalib returns your data in structured Python dictionaries with associated data types that make it easier to work with the data in Python. * Relationships can be traversed on model objects to pull in related content "seamlessly". * dates, times and durations are imported as Python objects making it easier to convert and work with this data. * Stravalib provides built-in support for rate-limiting * and more intelligent error handling. ```{toctree} :hidden: :maxdepth: 2 🠠Home ``` ```{toctree} :hidden: :caption: Get Started Get Started ``` ```{toctree} :hidden: :caption: API Documentation :maxdepth: 2 Code/API Reference ``` stravalib-1.3.0/docs/reference.rst000066400000000000000000000010471442076457100171360ustar00rootroot00000000000000.. _reference: API Reference ============= Welcome to the API documentation for the stravalib package. Below the main modules are listed. .. warning:: Note that only public facing methods are documented here. .. toctree:: :maxdepth: 1 Client Model Strava Model Unit helper Protocol Limiter (Util Submodule) Field Conversions Exceptions stravalib-1.3.0/docs/reference/000077500000000000000000000000001442076457100164025ustar00rootroot00000000000000stravalib-1.3.0/docs/reference/api/000077500000000000000000000000001442076457100171535ustar00rootroot00000000000000stravalib-1.3.0/docs/reference/api/stravalib.client.ActivityUploader.rst000066400000000000000000000011661442076457100264440ustar00rootroot00000000000000stravalib.client.ActivityUploader ================================= .. currentmodule:: stravalib.client .. autoclass:: ActivityUploader .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~ActivityUploader.__init__ ~ActivityUploader.poll ~ActivityUploader.raise_for_error ~ActivityUploader.update_from_response ~ActivityUploader.upload_photo ~ActivityUploader.wait .. rubric:: Attributes .. autosummary:: ~ActivityUploader.is_complete ~ActivityUploader.is_error ~ActivityUploader.is_processing ~ActivityUploader.photo_metadata stravalib-1.3.0/docs/reference/api/stravalib.client.ActivityUploader.update_from_response.rst000066400000000000000000000003211442076457100326560ustar00rootroot00000000000000stravalib.client.ActivityUploader.update\_from\_response ======================================================== .. currentmodule:: stravalib.client .. automethod:: ActivityUploader.update_from_response stravalib-1.3.0/docs/reference/api/stravalib.client.BatchedResultsIterator.rst000066400000000000000000000006761442076457100276070ustar00rootroot00000000000000stravalib.client.BatchedResultsIterator ======================================= .. currentmodule:: stravalib.client .. autoclass:: BatchedResultsIterator .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~BatchedResultsIterator.__init__ ~BatchedResultsIterator.next ~BatchedResultsIterator.reset .. rubric:: Attributes .. autosummary:: ~BatchedResultsIterator.default_per_page stravalib-1.3.0/docs/reference/api/stravalib.client.Client.authorization_url.rst000066400000000000000000000002501442076457100301440ustar00rootroot00000000000000stravalib.client.Client.authorization\_url ========================================== .. currentmodule:: stravalib.client .. automethod:: Client.authorization_url stravalib-1.3.0/docs/reference/api/stravalib.client.Client.create_activity.rst000066400000000000000000000002421442076457100275420ustar00rootroot00000000000000stravalib.client.Client.create\_activity ======================================== .. currentmodule:: stravalib.client .. automethod:: Client.create_activity stravalib-1.3.0/docs/reference/api/stravalib.client.Client.create_subscription.rst000066400000000000000000000002561442076457100304370ustar00rootroot00000000000000stravalib.client.Client.create\_subscription ============================================ .. currentmodule:: stravalib.client .. automethod:: Client.create_subscription stravalib-1.3.0/docs/reference/api/stravalib.client.Client.deauthorize.rst000066400000000000000000000002241442076457100267060ustar00rootroot00000000000000stravalib.client.Client.deauthorize =================================== .. currentmodule:: stravalib.client .. automethod:: Client.deauthorize stravalib-1.3.0/docs/reference/api/stravalib.client.Client.delete_activity.rst000066400000000000000000000002421442076457100275410ustar00rootroot00000000000000stravalib.client.Client.delete\_activity ======================================== .. currentmodule:: stravalib.client .. automethod:: Client.delete_activity stravalib-1.3.0/docs/reference/api/stravalib.client.Client.delete_subscription.rst000066400000000000000000000002561442076457100304360ustar00rootroot00000000000000stravalib.client.Client.delete\_subscription ============================================ .. currentmodule:: stravalib.client .. automethod:: Client.delete_subscription stravalib-1.3.0/docs/reference/api/stravalib.client.Client.exchange_code_for_token.rst000066400000000000000000000002761442076457100312140ustar00rootroot00000000000000stravalib.client.Client.exchange\_code\_for\_token ================================================== .. currentmodule:: stravalib.client .. automethod:: Client.exchange_code_for_token stravalib-1.3.0/docs/reference/api/stravalib.client.Client.explore_segments.rst000066400000000000000000000002451442076457100277510ustar00rootroot00000000000000stravalib.client.Client.explore\_segments ========================================= .. currentmodule:: stravalib.client .. automethod:: Client.explore_segments stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activities.rst000066400000000000000000000002371442076457100273720ustar00rootroot00000000000000stravalib.client.Client.get\_activities ======================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_activities stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity.rst000066400000000000000000000002311442076457100270540ustar00rootroot00000000000000stravalib.client.Client.get\_activity ===================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_activity stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity_comments.rst000066400000000000000000000002661442076457100307710ustar00rootroot00000000000000stravalib.client.Client.get\_activity\_comments =============================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_activity_comments stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity_kudos.rst000066400000000000000000000002551442076457100302670ustar00rootroot00000000000000stravalib.client.Client.get\_activity\_kudos ============================================ .. currentmodule:: stravalib.client .. automethod:: Client.get_activity_kudos stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity_laps.rst000066400000000000000000000002521442076457100300760ustar00rootroot00000000000000stravalib.client.Client.get\_activity\_laps =========================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_activity_laps stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity_photos.rst000066400000000000000000000002601442076457100304520ustar00rootroot00000000000000stravalib.client.Client.get\_activity\_photos ============================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_activity_photos stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity_streams.rst000066400000000000000000000002631442076457100306170ustar00rootroot00000000000000stravalib.client.Client.get\_activity\_streams ============================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_activity_streams stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_activity_zones.rst000066400000000000000000000002551442076457100303000ustar00rootroot00000000000000stravalib.client.Client.get\_activity\_zones ============================================ .. currentmodule:: stravalib.client .. automethod:: Client.get_activity_zones stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_athlete.rst000066400000000000000000000002261442076457100266520ustar00rootroot00000000000000stravalib.client.Client.get\_athlete ==================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_athlete stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_athlete_clubs.rst000066400000000000000000000002521442076457100300410ustar00rootroot00000000000000stravalib.client.Client.get\_athlete\_clubs =========================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_athlete_clubs stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_athlete_koms.rst000066400000000000000000000002471442076457100277060ustar00rootroot00000000000000stravalib.client.Client.get\_athlete\_koms ========================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_athlete_koms stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_athlete_starred_segments.rst000066400000000000000000000003151442076457100323020ustar00rootroot00000000000000stravalib.client.Client.get\_athlete\_starred\_segments ======================================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_athlete_starred_segments stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_athlete_stats.rst000066400000000000000000000002521442076457100300670ustar00rootroot00000000000000stravalib.client.Client.get\_athlete\_stats =========================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_athlete_stats stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_club.rst000066400000000000000000000002151442076457100261470ustar00rootroot00000000000000stravalib.client.Client.get\_club ================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_club stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_club_activities.rst000066400000000000000000000002601442076457100303730ustar00rootroot00000000000000stravalib.client.Client.get\_club\_activities ============================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_club_activities stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_club_members.rst000066400000000000000000000002471442076457100276660ustar00rootroot00000000000000stravalib.client.Client.get\_club\_members ========================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_club_members stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_effort_streams.rst000066400000000000000000000002551442076457100302510ustar00rootroot00000000000000stravalib.client.Client.get\_effort\_streams ============================================ .. currentmodule:: stravalib.client .. automethod:: Client.get_effort_streams stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_friend_activities.rst000066400000000000000000000002661442076457100307230ustar00rootroot00000000000000stravalib.client.Client.get\_friend\_activities =============================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_friend_activities stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_gear.rst000066400000000000000000000002151442076457100261400ustar00rootroot00000000000000stravalib.client.Client.get\_gear ================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_gear stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_related_activities.rst000066400000000000000000000002711442076457100310700ustar00rootroot00000000000000stravalib.client.Client.get\_related\_activities ================================================ .. currentmodule:: stravalib.client .. automethod:: Client.get_related_activities stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_route.rst000066400000000000000000000002201442076457100263540ustar00rootroot00000000000000stravalib.client.Client.get\_route ================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_route stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_route_streams.rst000066400000000000000000000002521442076457100301170ustar00rootroot00000000000000stravalib.client.Client.get\_route\_streams =========================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_route_streams stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_routes.rst000066400000000000000000000002231442076457100265420ustar00rootroot00000000000000stravalib.client.Client.get\_routes =================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_routes stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_segment.rst000066400000000000000000000002261442076457100266660ustar00rootroot00000000000000stravalib.client.Client.get\_segment ==================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_segment stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_segment_effort.rst000066400000000000000000000002551442076457100302350ustar00rootroot00000000000000stravalib.client.Client.get\_segment\_effort ============================================ .. currentmodule:: stravalib.client .. automethod:: Client.get_segment_effort stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_segment_efforts.rst000066400000000000000000000002601442076457100304140ustar00rootroot00000000000000stravalib.client.Client.get\_segment\_efforts ============================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_segment_efforts stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_segment_streams.rst000066400000000000000000000002601442076457100304220ustar00rootroot00000000000000stravalib.client.Client.get\_segment\_streams ============================================= .. currentmodule:: stravalib.client .. automethod:: Client.get_segment_streams stravalib-1.3.0/docs/reference/api/stravalib.client.Client.get_starred_segments.rst000066400000000000000000000002631442076457100305760ustar00rootroot00000000000000stravalib.client.Client.get\_starred\_segments ============================================== .. currentmodule:: stravalib.client .. automethod:: Client.get_starred_segments stravalib-1.3.0/docs/reference/api/stravalib.client.Client.handle_subscription_callback.rst000066400000000000000000000003131442076457100322350ustar00rootroot00000000000000stravalib.client.Client.handle\_subscription\_callback ====================================================== .. currentmodule:: stravalib.client .. automethod:: Client.handle_subscription_callback stravalib-1.3.0/docs/reference/api/stravalib.client.Client.handle_subscription_update.rst000066400000000000000000000003051442076457100317640ustar00rootroot00000000000000stravalib.client.Client.handle\_subscription\_update ==================================================== .. currentmodule:: stravalib.client .. automethod:: Client.handle_subscription_update stravalib-1.3.0/docs/reference/api/stravalib.client.Client.join_club.rst000066400000000000000000000002201442076457100263230ustar00rootroot00000000000000stravalib.client.Client.join\_club ================================== .. currentmodule:: stravalib.client .. automethod:: Client.join_club stravalib-1.3.0/docs/reference/api/stravalib.client.Client.leave_club.rst000066400000000000000000000002231442076457100264630ustar00rootroot00000000000000stravalib.client.Client.leave\_club =================================== .. currentmodule:: stravalib.client .. automethod:: Client.leave_club stravalib-1.3.0/docs/reference/api/stravalib.client.Client.list_subscriptions.rst000066400000000000000000000002531442076457100303270ustar00rootroot00000000000000stravalib.client.Client.list\_subscriptions =========================================== .. currentmodule:: stravalib.client .. automethod:: Client.list_subscriptions stravalib-1.3.0/docs/reference/api/stravalib.client.Client.refresh_access_token.rst000066400000000000000000000002631442076457100305450ustar00rootroot00000000000000stravalib.client.Client.refresh\_access\_token ============================================== .. currentmodule:: stravalib.client .. automethod:: Client.refresh_access_token stravalib-1.3.0/docs/reference/api/stravalib.client.Client.rst000066400000000000000000000032671442076457100243760ustar00rootroot00000000000000stravalib.client.Client ======================= .. currentmodule:: stravalib.client .. autoclass:: Client .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~Client.__init__ ~Client.authorization_url ~Client.create_activity ~Client.create_subscription ~Client.deauthorize ~Client.delete_activity ~Client.delete_subscription ~Client.exchange_code_for_token ~Client.explore_segments ~Client.get_activities ~Client.get_activity ~Client.get_activity_comments ~Client.get_activity_kudos ~Client.get_activity_laps ~Client.get_activity_photos ~Client.get_activity_streams ~Client.get_activity_zones ~Client.get_athlete ~Client.get_athlete_clubs ~Client.get_athlete_koms ~Client.get_athlete_starred_segments ~Client.get_athlete_stats ~Client.get_club ~Client.get_club_activities ~Client.get_club_members ~Client.get_effort_streams ~Client.get_friend_activities ~Client.get_gear ~Client.get_related_activities ~Client.get_route ~Client.get_route_streams ~Client.get_routes ~Client.get_segment ~Client.get_segment_effort ~Client.get_segment_efforts ~Client.get_segment_streams ~Client.get_starred_segments ~Client.handle_subscription_callback ~Client.handle_subscription_update ~Client.join_club ~Client.leave_club ~Client.list_subscriptions ~Client.refresh_access_token ~Client.update_activity ~Client.update_athlete ~Client.upload_activity .. rubric:: Attributes .. autosummary:: ~Client.access_token stravalib-1.3.0/docs/reference/api/stravalib.client.Client.update_activity.rst000066400000000000000000000002421442076457100275610ustar00rootroot00000000000000stravalib.client.Client.update\_activity ======================================== .. currentmodule:: stravalib.client .. automethod:: Client.update_activity stravalib-1.3.0/docs/reference/api/stravalib.client.Client.update_athlete.rst000066400000000000000000000002371442076457100273570ustar00rootroot00000000000000stravalib.client.Client.update\_athlete ======================================= .. currentmodule:: stravalib.client .. automethod:: Client.update_athlete stravalib-1.3.0/docs/reference/api/stravalib.client.Client.upload_activity.rst000066400000000000000000000002421442076457100275630ustar00rootroot00000000000000stravalib.client.Client.upload\_activity ======================================== .. currentmodule:: stravalib.client .. automethod:: Client.upload_activity stravalib-1.3.0/docs/reference/api/stravalib.field_conversions.enum_value.rst000066400000000000000000000002431442076457100275440ustar00rootroot00000000000000stravalib.field\_conversions.enum\_value ======================================== .. currentmodule:: stravalib.field_conversions .. autofunction:: enum_value stravalib-1.3.0/docs/reference/api/stravalib.field_conversions.enum_values.rst000066400000000000000000000002461442076457100277320ustar00rootroot00000000000000stravalib.field\_conversions.enum\_values ========================================= .. currentmodule:: stravalib.field_conversions .. autofunction:: enum_values stravalib-1.3.0/docs/reference/api/stravalib.field_conversions.optional_input.rst000066400000000000000000000002571442076457100304550ustar00rootroot00000000000000stravalib.field\_conversions.optional\_input ============================================ .. currentmodule:: stravalib.field_conversions .. autofunction:: optional_input stravalib-1.3.0/docs/reference/api/stravalib.field_conversions.time_interval.rst000066400000000000000000000002541442076457100302500ustar00rootroot00000000000000stravalib.field\_conversions.time\_interval =========================================== .. currentmodule:: stravalib.field_conversions .. autofunction:: time_interval stravalib-1.3.0/docs/reference/api/stravalib.field_conversions.timezone.rst000066400000000000000000000002331442076457100272350ustar00rootroot00000000000000stravalib.field\_conversions.timezone ===================================== .. currentmodule:: stravalib.field_conversions .. autofunction:: timezone stravalib-1.3.0/docs/reference/api/stravalib.unithelper.Quantity.rst000066400000000000000000000005051442076457100256670ustar00rootroot00000000000000stravalib.unithelper.Quantity ============================= .. currentmodule:: stravalib.unithelper .. autoclass:: Quantity .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~Quantity.__init__ .. rubric:: Attributes .. autosummary:: ~Quantity.num ~Quantity.unit stravalib-1.3.0/docs/reference/api/stravalib.unithelper.UnitConverter.rst000066400000000000000000000003731442076457100266630ustar00rootroot00000000000000stravalib.unithelper.UnitConverter ================================== .. currentmodule:: stravalib.unithelper .. autoclass:: UnitConverter .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~UnitConverter.__init__ stravalib-1.3.0/docs/reference/api/stravalib.unithelper.UnitsQuantity.rst000066400000000000000000000005431442076457100267140ustar00rootroot00000000000000stravalib.unithelper.UnitsQuantity ================================== .. currentmodule:: stravalib.unithelper .. autoclass:: UnitsQuantity .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~UnitsQuantity.__init__ .. rubric:: Attributes .. autosummary:: ~UnitsQuantity.num ~UnitsQuantity.unit stravalib-1.3.0/docs/reference/api/stravalib.unithelper.c2f.rst000066400000000000000000000001651442076457100245250ustar00rootroot00000000000000stravalib.unithelper.c2f ======================== .. currentmodule:: stravalib.unithelper .. autofunction:: c2f stravalib-1.3.0/docs/reference/api/stravalib.unithelper.is_quantity_type.rst000066400000000000000000000002401442076457100274570ustar00rootroot00000000000000stravalib.unithelper.is\_quantity\_type ======================================= .. currentmodule:: stravalib.unithelper .. autofunction:: is_quantity_type stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.DefaultRateLimiter.rst000066400000000000000000000004251442076457100300420ustar00rootroot00000000000000stravalib.util.limiter.DefaultRateLimiter ========================================= .. currentmodule:: stravalib.util.limiter .. autoclass:: DefaultRateLimiter .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~DefaultRateLimiter.__init__ stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.RateLimitRule.rst000066400000000000000000000004011442076457100270300ustar00rootroot00000000000000stravalib.util.limiter.RateLimitRule ==================================== .. currentmodule:: stravalib.util.limiter .. autoclass:: RateLimitRule .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~RateLimitRule.__init__ stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.RateLimiter.rst000066400000000000000000000003711442076457100265350ustar00rootroot00000000000000stravalib.util.limiter.RateLimiter ================================== .. currentmodule:: stravalib.util.limiter .. autoclass:: RateLimiter .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~RateLimiter.__init__ stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.SleepingRateLimitRule.rst000066400000000000000000000004411442076457100305230ustar00rootroot00000000000000stravalib.util.limiter.SleepingRateLimitRule ============================================ .. currentmodule:: stravalib.util.limiter .. autoclass:: SleepingRateLimitRule .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~SleepingRateLimitRule.__init__ stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.XRateLimitRule.rst000066400000000000000000000005361442076457100271710ustar00rootroot00000000000000stravalib.util.limiter.XRateLimitRule ===================================== .. currentmodule:: stravalib.util.limiter .. autoclass:: XRateLimitRule .. automethod:: __init__ .. rubric:: Methods .. autosummary:: ~XRateLimitRule.__init__ .. rubric:: Attributes .. autosummary:: ~XRateLimitRule.limit_timeout stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.get_rates_from_response_headers.rst000066400000000000000000000003271442076457100327260ustar00rootroot00000000000000stravalib.util.limiter.get\_rates\_from\_response\_headers ========================================================== .. currentmodule:: stravalib.util.limiter .. autofunction:: get_rates_from_response_headers stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.get_seconds_until_next_day.rst000066400000000000000000000003101442076457100317100ustar00rootroot00000000000000stravalib.util.limiter.get\_seconds\_until\_next\_day ===================================================== .. currentmodule:: stravalib.util.limiter .. autofunction:: get_seconds_until_next_day stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.get_seconds_until_next_quarter.rst000066400000000000000000000003241442076457100326230ustar00rootroot00000000000000stravalib.util.limiter.get\_seconds\_until\_next\_quarter ========================================================= .. currentmodule:: stravalib.util.limiter .. autofunction:: get_seconds_until_next_quarter stravalib-1.3.0/docs/reference/api/stravalib.util.limiter.total_seconds.rst000066400000000000000000000002331442076457100271520ustar00rootroot00000000000000stravalib.util.limiter.total\_seconds ===================================== .. currentmodule:: stravalib.util.limiter .. autofunction:: total_seconds stravalib-1.3.0/docs/reference/client.rst000066400000000000000000000051071442076457100204150ustar00rootroot00000000000000============ Client ============ .. currentmodule:: stravalib.client The ``Client`` object class for interacting with the Strava v3 API. While you can create this object without an access token, you will likely want to create an access token to authenticate and access most of the Strava data accessible via the API. Main Classes ------------- .. autosummary:: :toctree: api/ Client BatchedResultsIterator ActivityUploader General methods and attributes ------------------------------- .. autosummary:: :toctree: api/ Client.authorization_url Client.exchange_code_for_token Client.refresh_access_token Client.deauthorize Athlete methods ----------------- .. autosummary:: :toctree: api/ Client.get_activities Client.get_athlete Client.update_athlete Client.get_athlete_koms Client.get_athlete_stats Client.get_athlete_clubs Client.get_gear Club related methods -------------------- .. autosummary:: :toctree: api/ Client.join_club Client.leave_club Client.get_club Client.get_club_members Client.get_club_activities Activity related methods ------------------------- .. autosummary:: :toctree: api/ Client.get_activity Client.get_friend_activities Client.create_activity Client.update_activity Client.upload_activity Client.delete_activity Client.get_activity_zones Client.get_activity_comments Client.get_activity_kudos Client.get_activity_photos Client.get_activity_laps Client.get_related_activities Segment related methods ------------------------- .. autosummary:: :toctree: api/ Client.get_segment_effort Client.get_segment Client.get_starred_segments Client.get_athlete_starred_segments Client.get_segment_efforts Client.explore_segments Stream related methods ------------------------- .. autosummary:: :toctree: api/ Client.get_activity_streams Client.get_effort_streams Client.get_segment_streams Route related methods ---------------------- .. autosummary:: :toctree: api/ Client.get_routes Client.get_route Client.get_route_streams Subscription related methods ----------------------------- .. autosummary:: :toctree: api/ Client.create_subscription Client.handle_subscription_callback Client.handle_subscription_update Client.list_subscriptions Client.delete_subscription Activity Uploader Constructor ----------------------------- .. autosummary:: :toctree: api/ ActivityUploader ActivityUploader methods --------------------------- .. autosummary:: :toctree: api/ ActivityUploader.update_from_response stravalib-1.3.0/docs/reference/exceptions.rst000066400000000000000000000001241442076457100213120ustar00rootroot00000000000000.. automodule:: stravalib.exc :members: :undoc-members: :show-inheritance: stravalib-1.3.0/docs/reference/field-conversions.rst000066400000000000000000000006331442076457100225670ustar00rootroot00000000000000============================ Field Conversions Module ============================ .. currentmodule:: stravalib.field_conversions The `field_conversions` module provides helper utility functions to convert fields to (rich) types, e.g., `int` → `timedelta`. Summary Functions ------------------ .. autosummary:: :toctree: api/ optional_input enum_value enum_values time_interval timezone stravalib-1.3.0/docs/reference/model.rst000066400000000000000000000021101442076457100202260ustar00rootroot00000000000000.. automodule:: stravalib.model :inherited-members: BaseModel .. If you use both automodule and autosummary you duplicate autodocs efforts .. Summary Functions .. ------------------------------- .. .. autosummary:: .. :toctree: api/ .. lazy_property .. check_valid_location .. naive_datetime .. Summary Classes .. ------------------------------- .. .. autosummary:: .. :toctree: api/ .. DeprecatedSerializableMixin .. BackwardCompatibilityMixin .. BoundClientEntity .. LatLon .. Club .. Gear .. Bike .. Shoe .. ActivityTotals .. AthleteStats .. Athlete .. ActivityComment .. ActivityPhotoPrimary .. ActivityPhotoMeta .. ActivityPhoto .. ActivityKudos .. ActivityLap .. Map .. Split .. SegmentExplorerResult .. AthleteSegmentStats .. AthletePrEffort .. Segment .. SegmentEffortAchievement .. BaseEffort .. BestEffort .. SegmentEffort .. Activity .. DistributionBucket .. BaseActivityZone .. Stream .. Route .. Subscription .. SubscriptionCallback .. SubscriptionUpdate stravalib-1.3.0/docs/reference/protocol.rst000066400000000000000000000004751442076457100210030ustar00rootroot00000000000000================= Protocol Module ================= .. currentmodule:: stravalib.model This module contains only one class. This class handles interactions with the Strava V3 API including steps in the authentication process. .. automodule:: stravalib.protocol :members: :undoc-members: :show-inheritance: stravalib-1.3.0/docs/reference/strava_model.rst000066400000000000000000000005301442076457100216120ustar00rootroot00000000000000============================ Strava Model Module ============================ .. currentmodule:: stravalib.strava_model The `strava-model` module contains the officially documented Strava API entities. This module is generated by a bot and should not be edited manually. .. automodule:: stravalib.strava_model :members: :undoc-members: stravalib-1.3.0/docs/reference/unithelper.rst000066400000000000000000000006331442076457100213150ustar00rootroot00000000000000============================ Unit Helper Module ============================ .. currentmodule:: stravalib.unithelper The `unithelper` module provides helper utility to convert various units. Summary Functions ------------------ .. autosummary:: :toctree: api/ is_quantity_type c2f Summary Classes ---------------- .. autosummary:: :toctree: api/ UnitsQuantity Quantity UnitConverter stravalib-1.3.0/docs/reference/utilities.rst000066400000000000000000000014201442076457100211440ustar00rootroot00000000000000====================================================== Limiter - in Utility Submodule: Functions and Classes ====================================================== .. currentmodule:: stravalib.util.limiter This module provides a mixture of helpers to support rate limiting and also functions for conversion? **TODO:** look into whether total_seconds relates to limiter actions or unit conversion Summary Functions ------------------------------- .. autosummary:: :toctree: api/ total_seconds get_rates_from_response_headers get_seconds_until_next_quarter get_seconds_until_next_day Summary Classes ------------------------------- .. autosummary:: :toctree: api/ XRateLimitRule SleepingRateLimitRule RateLimitRule RateLimiter DefaultRateLimiter stravalib-1.3.0/environment.yml000066400000000000000000000001261442076457100166020ustar00rootroot00000000000000name: stravalib-dev channels: - conda-forge dependencies: - python=3.9 - sphinx stravalib-1.3.0/examples/000077500000000000000000000000001442076457100153325ustar00rootroot00000000000000stravalib-1.3.0/examples/strava-oauth/000077500000000000000000000000001442076457100177505ustar00rootroot00000000000000stravalib-1.3.0/examples/strava-oauth/README.md000066400000000000000000000015241442076457100212310ustar00rootroot00000000000000This is an example Flask application showing how you can use stravalib to help with getting access tokens. Create Virtualenv ================= We'll assume you're using python3. ``` $ cd /path/to/stravalib $ python3 -m venv env $ source env/bin/activate (env) $ pip install -r requirements.txt && python setup.py develop (env) $ pip install -r examples/strava-oauth/requirements.txt ``` Create a Config File ==================== Create a file -- for example `settings.cfg`: ``` (env) $ cd examples/strava-oauth/ (env) $ vi settings.cfg ``` Paste in your Strava client ID and secret: ```python STRAVA_CLIENT_ID=123 STRAVA_CLIENT_SECRET='deadbeefdeadbeefdeadbeef' ``` Run Server ========== Run the Flask server, specifying the path to this file in your `APP_SETTINGS` environment var: ``` (env) $ APP_SETTINGS=settings.cfg python server.py ``` stravalib-1.3.0/examples/strava-oauth/requirements.txt000066400000000000000000000000151442076457100232300ustar00rootroot00000000000000Flask~=1.0.2 stravalib-1.3.0/examples/strava-oauth/server.py000066400000000000000000000026161442076457100216350ustar00rootroot00000000000000#!flask/bin/python import logging from flask import Flask, jsonify, redirect, render_template, request, url_for from stravalib import Client app = Flask(__name__) app.config.from_envvar("APP_SETTINGS") @app.route("/") def login(): c = Client() url = c.authorization_url( client_id=app.config["STRAVA_CLIENT_ID"], redirect_uri=url_for(".logged_in", _external=True), approval_prompt="auto", ) return render_template("login.html", authorize_url=url) @app.route("/strava-oauth") def logged_in(): """ Method called by Strava (redirect) that includes parameters. - state - code - error """ error = request.args.get("error") state = request.args.get("state") if error: return render_template("login_error.html", error=error) else: code = request.args.get("code") client = Client() access_token = client.exchange_code_for_token( client_id=app.config["STRAVA_CLIENT_ID"], client_secret=app.config["STRAVA_CLIENT_SECRET"], code=code, ) # Probably here you'd want to store this somewhere -- e.g. in a database. strava_athlete = client.get_athlete() return render_template( "login_results.html", athlete=strava_athlete, access_token=access_token, ) if __name__ == "__main__": app.run(debug=True) stravalib-1.3.0/examples/strava-oauth/static/000077500000000000000000000000001442076457100212375ustar00rootroot00000000000000stravalib-1.3.0/examples/strava-oauth/static/ConnectWithStrava.png000066400000000000000000000052031442076457100253530ustar00rootroot00000000000000‰PNG  IHDR+šp&`tEXtSoftwareAdobe ImageReadyqÉe< %IDATxÚì\MlTU¾oZMˆã‚V+ˆ &ÂŽ†º$Y [Šî”¤Xu§”­?LÀ•Rde„´;]ˆ%°2Jqa $š &Jg¼ß™{nÏ»ï¾y3÷¦çK†o^ß½ïÜs¾sÎ÷î41î«°?†ŒB¡X/˜í¿T¿ $!œ¶¯Íj'…bÝaÞ¾^c‚H)|£¶Q(Ö=^9$–P!Ìi¥ P(\å0X³ÿPRP(à‚ †µ…B¡¨©  E%…B¡Ä P(V1lP++J ÁG¿0ÉŽ=ji…B‰¡ ½WK+J Ž!0A(ŠuN !hÕ P¬ôW]-„DѸ>£VWtç[O=gÌàI¶Ðÿ×§MãöïÆÜ¹Õ˜CÌ÷é^ä¸v ŒE×¹8¹¶ˆ!¯uY4®ïíÝÝÁA0é@JL¥­±tä–kà¤ÝàÊs÷¨©½õqæIWrh¼ùÆCýÜ Zo¬MÀ/ Û]{ýÆå³¦>5¿àµÃã¦aƒ½qùŒ©}x%õqýäët\Ú¦6y5=ó'ˆôpŸ &»^¯ïÃ}µ÷íÏRë|$ÏÈõ÷öVœX < ±Ž[Üú§ïxV_“A;òª'Ãú¹‰JÆèûvÁÖ”ÖýpÓ·oùÀ1ÒnÖžHáØ×¥dÿ%ƒ¨èžr÷ºðÆÓÙ'rÖ÷pÜŸÏíZÉyãó¾S?iÀ† “eÀDéC‘ÐX¹ÖR1ÅHYÚ:Xîçk…¬ƒvš5»o›c¦œ½¬dƒJ¡MRHeåŠZ™ÚèXÆï’ýGIØÊáüD¶zbÛÈ÷L8O5ýÒUDôù2íê/ß9Ž·EUU p êÛœ“Pu€l`ËYÌIË—jŠ%«„nZƒNÛÂLå‡l ­>åˆžŽ¡Zþ•éãsúýXᵂpüíéäGD!Æ@P£­ +ÆÚŒÃãBC‹BÕBª _–ª¡TbˆU ¸)2‚0leZX[°pýƒƒ~qèoôeë~ñ1÷ýcÍùcq¾%,.;?ebw}d%8#;$ÎE†à~›ûK:=ïÈ+ÍòÐ~Q)•ÕPåÀ±v6YÇzLšæéÈóǵh{,yó“EbäyÌÍA†YçÄç©{Äý8ûPÆò¤à€ý]3x-ewʨn\óàn¼Å³ïC›u¬a7Üý#;Ûùq‹(íkm=±I;呟«÷›<¿ÕBX5$;®¤+V´Õ‚\oñƒUE Xô¬Xcû¤à3fø²û|ÙP†Y¬lÉ‚™ÆÏÌŽg°Ï^sáÈóô»àÎÁBG#’°Á=¤?/ÈVv¬:lƒùqë#‚™ Îíœ"Ó“Ê ‚—µgf^ø,§O®ñÜð“‰÷Áטõs„-R6sçàÚ‰ þÔ¸8ŸÇ½1“öi 7Ù¬¨ò€  Á˜¸¾'æ[vaÎ-4‹°Z(ª2-ªŸïϘ¾.ǯ¿ UCiC¬¢ŒÇì,VÛ=c'sØ*Æÿq*W8¢, r"•Ÿ ˆÃ.‰dœqÀð±¹ò¹X0vj‰ pçTDޏ¦ Bp8•Ïþî<ŸÝÅ܈8“Cü²G$ÇÕ sÂ8"Óÿ?{;n ž ä ám΋õïpj™Yœ‹K6ó¦±C›ÅZ— âÉóAdöv®×®ÀJ¯ÏË>^NµÐRkïÍÆIíÅ|=a9´†Òˆ!Ó3ݹ•r"&‰¨³œB·œq\Ö¡€wd›'e>?h b½0]S:7 LìTÒ^¶÷™Ù•Ýœ±iž6ਠG»á4ß21á‰>9¯TOÕŽáhuÃ-×b@LG³yª:C+×Û s·¥³Ç`ñ%§{ÛY\eÂVc§+õ"¾©É–Õ‚ jì±ÈÕ4\ËÚŠ\¢Õøjh%ZU æñ'šÆüù;cþþÓÿß 18θ÷—1ÿý[’Xµ­…2ì‚fÚ:ÿP4P8¨’‘Å ‘@ÿìán¾aã,‡ce*W&‘>7ÖŽH'ËS'ŽûåG{»,ÝŸ_¼o7¦öȉ—¢ˆ€Z¼‡i_R'7GÛh¹Gx4/¬ë2¡½ª" Example Strava Login

Login using Strava

Authenticate using your Strava account.

stravalib-1.3.0/examples/strava-oauth/templates/login_error.html000066400000000000000000000007331442076457100251600ustar00rootroot00000000000000 Example Strava Login Error

Authorization Failed

There was an error authorizing the application to access your Strava account.

Error code: {{ error }}

stravalib-1.3.0/examples/strava-oauth/templates/login_results.html000066400000000000000000000007201442076457100255240ustar00rootroot00000000000000 Example Strava Login Results

Authorization Results

Hi, {{ athlete.firstname }}. Your access token:

{{ access_token|tojson(indent=2) }}

stravalib-1.3.0/pyproject.toml000066400000000000000000000044231442076457100164330ustar00rootroot00000000000000[build-system] requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] # Make sure setuptools uses version of last created tag - this allows us to specify bump version_scheme = "post-release" # Make sure scm doesn't use local scheme version for push to pypi # (so there isn't a + in the version) local_scheme = "no-local-version" write_to = "stravalib/_version_generated.py" write_to_template = '__version__ = "v{version}"' [project] name = "stravalib" description = "A Python package that makes it easy to access and download data from the Strava V3 REST API." keywords = ["strava", "running", "cycling", "athletes"] readme = "README.md" dynamic =["version"] license = {text = "Apache 2.0 License"} authors = [{name="Hans Lellelid", email="hans@xmpl.org"}] maintainers = [ {name = "Leah Wasser"}, {name = "Hans Lellelid"}, {name = "Jonatan Samoocha"}, {name = "Yihong"}, ] requires-python = ">=3.8" classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Intended Audience :: Science/Research", "Intended Audience :: Developers", # Because license is listed below does it need to be here too? "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] dependencies = [ "pint", "pytz", "arrow", "requests", "pydantic", ] [project.urls] homepage = "https://example.com" documentation = "https://stravalib.readthedocs.io" repository = "https://github.com/stravalib/stravalib" changelog = "https://github.com/stravalib/stravalib/blob/master/changelog.md" [tool.black] line-length = 79 # When editing the config for black in this file, be sure to make # the same edits in the repo stravalib/strava_swagger2pydantic [tool.isort] profile = "black" [tool.pytest.ini_options] filterwarnings = [ "ignore::FutureWarning:stravalib.*", "ignore::DeprecationWarning:stravalib.*" ] stravalib-1.3.0/requirements-build.txt000066400000000000000000000000341442076457100200720ustar00rootroot00000000000000# Package build build twine stravalib-1.3.0/requirements.txt000066400000000000000000000006521442076457100170030ustar00rootroot00000000000000# Package deps arrow pint pydantic pytz requests>=2.0,<3.0dev responses # Pre commit hook pre-commit # Testing pytest # Code coverage pytest-cov # Docs # Pinning to avoid a current bug in 0.13 pydata-sphinx-theme==0.12.0 # Support pydantic autodoc autodoc_pydantic sphinx_remove_toctrees myst-nb sphinx-autobuild sphinx-inline-tabs sphinx_copybutton sphinx_design # Support for social / adds meta tags sphinxext-opengraph stravalib-1.3.0/stravalib/000077500000000000000000000000001442076457100155035ustar00rootroot00000000000000stravalib-1.3.0/stravalib/__init__.py000066400000000000000000000002151442076457100176120ustar00rootroot00000000000000from stravalib.client import Client try: from ._version_generated import __version__ except ImportError: __version__ = "unreleased" stravalib-1.3.0/stravalib/client.py000066400000000000000000002100151442076457100173320ustar00rootroot00000000000000""" Client ============== Provides the main interface classes for the Strava version 3 REST API. """ from __future__ import annotations import calendar import collections import functools import logging import time from datetime import datetime, timedelta from io import BytesIO from typing import Dict, List, Optional import arrow import pytz from stravalib import exc, model, unithelper from stravalib.exc import ( ActivityPhotoUploadNotSupported, warn_attribute_unofficial, warn_method_unofficial, warn_param_deprecation, warn_param_unofficial, warn_param_unsupported, ) from stravalib.protocol import ApiV3 from stravalib.unithelper import is_quantity_type from stravalib.util import limiter class Client(object): """Main client class for interacting with the exposed Strava v3 API methods. This class can be instantiated without an access_token when performing authentication; however, most methods will require a valid access token. """ def __init__( self, access_token=None, rate_limit_requests=True, rate_limiter=None, requests_session=None, ): """ Initialize a new client object. Parameters ---------- access_token : str The token that provides access to a specific Strava account. If empty, assume that this account is not yet authenticated. rate_limit_requests : bool Whether to apply a rate limiter to the requests. (default True) rate_limiter : callable A :class:`stravalib.util.limiter.RateLimiter` object to use. If not specified (and rate_limit_requests is True), then :class:`stravalib.util.limiter.DefaultRateLimiter` will be used. requests_session : requests.Session() object (Optional) pass request session object. """ self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) if rate_limit_requests: if not rate_limiter: rate_limiter = limiter.DefaultRateLimiter() elif rate_limiter: raise ValueError( "Cannot specify rate_limiter object when rate_limit_requests is" " False" ) self.protocol = ApiV3( access_token=access_token, requests_session=requests_session, rate_limiter=rate_limiter, ) @property def access_token(self): """The currently configured authorization token.""" return self.protocol.access_token @access_token.setter def access_token(self, token_value): """Set the currently configured authorization token. Parameters ---------- token_value : int User's access token for authentication. Returns ------- """ self.protocol.access_token = token_value def authorization_url( self, client_id, redirect_uri, approval_prompt="auto", scope=None, state=None, ): """Get the URL needed to authorize your application to access a Strava user's information. See https://developers.strava.com/docs/authentication/ Parameters ---------- client_id : int The numeric developer client id. redirect_uri : str The URL that Strava will redirect to after successful (or failed) authorization. approval_prompt : str, default='auto' Whether to prompt for approval even if approval already granted to app. Choices are 'auto' or 'force'. scope : list[str], default = None The access scope required. Omit to imply "read" and "activity:read" Valid values are 'read', 'read_all', 'profile:read_all', 'profile:write', 'activity:read', 'activity:read_all', 'activity:write'. state : str, default=None An arbitrary variable that will be returned to your application in the redirect URI. Returns ------- str: A string containing the url required to authorize with the Strava API. """ return self.protocol.authorization_url( client_id=client_id, redirect_uri=redirect_uri, approval_prompt=approval_prompt, scope=scope, state=state, ) def exchange_code_for_token(self, client_id, client_secret, code): """Exchange the temporary authorization code (returned with redirect from strava authorization URL) for a short-lived access token and a refresh token (used to obtain the next access token later on). Parameters ---------- client_id : int The numeric developer client id. client_secret : str The developer client secret code : str The temporary authorization code Returns ------- dict Dictionary containing the access_token, refresh_token and expires_at (number of seconds since Epoch when the provided access token will expire) """ return self.protocol.exchange_code_for_token( client_id=client_id, client_secret=client_secret, code=code ) def refresh_access_token(self, client_id, client_secret, refresh_token): """Exchanges the previous refresh token for a short-lived access token and a new refresh token (used to obtain the next access token later on). Parameters ---------- client_id : int The numeric developer client id. client_secret : str The developer client secret refresh_token : str The refresh token obtained from a previous authorization request Returns ------- dict: Dictionary containing the access_token, refresh_token and expires_at (number of seconds since Epoch when the provided access token will expire) """ return self.protocol.refresh_access_token( client_id=client_id, client_secret=client_secret, refresh_token=refresh_token, ) def deauthorize(self): """Deauthorize the application. This causes the application to be removed from the athlete's "My Apps" settings page. https://developers.strava.com/docs/authentication/#deauthorization """ self.protocol.post("oauth/deauthorize") def _utc_datetime_to_epoch(self, activity_datetime): """Convert the specified datetime value to a unix epoch timestamp (seconds since epoch). Parameters ---------- activity_datetime : str A string which may contain tzinfo (offset) or a datetime object (naive datetime will be considered to be UTC). Returns ------- datetime value in univ epoch time stamp format (seconds since epoch) """ if isinstance(activity_datetime, str): activity_datetime = arrow.get(activity_datetime).datetime assert isinstance(activity_datetime, datetime) if activity_datetime.tzinfo: activity_datetime = activity_datetime.astimezone(pytz.utc) return calendar.timegm(activity_datetime.timetuple()) def get_activities(self, before=None, after=None, limit=None): """Get activities for authenticated user sorted by newest first. https://developers.strava.com/docs/reference/#api-Activities-getLoggedInAthleteActivities Parameters ---------- before : datetime.datetime or str or None, default=None Result will start with activities whose start date is before specified date. (UTC) after : datetime.datetime or str or None, default=None Result will start with activities whose start date is after specified value. (UTC) limit : int or None, default=None How many maximum activities to return. Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Activity` objects. """ if before: before = self._utc_datetime_to_epoch(before) if after: after = self._utc_datetime_to_epoch(after) params = dict(before=before, after=after) result_fetcher = functools.partial( self.protocol.get, "/athlete/activities", **params ) return BatchedResultsIterator( entity=model.Activity, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_athlete(self): """Gets the specified athlete; if athlete_id is None then retrieves a detail-level representation of currently authenticated athlete; otherwise summary-level representation returned of athlete. https://developers.strava.com/docs/reference/#api-Athletes https://developers.strava.com/docs/reference/#api-Athletes-getLoggedInAthlete Parameters ---------- Returns ------- class:`stravalib.model.Athlete` The athlete model object. """ raw = self.protocol.get("/athlete") return model.Athlete.parse_obj({**raw, **{"bound_client": self}}) def update_athlete( self, city=None, state=None, country=None, sex=None, weight=None ): """Updates the properties of the authorized athlete. https://developers.strava.com/docs/reference/#api-Athletes-updateLoggedInAthlete Parameters ---------- city : str, default=None City the athlete lives in .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. state : str, default=None State the athlete lives in .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. country : str, default=None Country the athlete lives in .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. sex : str, default=None Sex of the athlete .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. weight : float, default=None Weight of the athlete in kg (float) """ params = {"city": city, "state": state, "country": country, "sex": sex} params = {k: v for (k, v) in params.items() if v is not None} for p in params.keys(): if p != "weight": warn_param_unsupported(p) if weight is not None: params["weight"] = float(weight) raw_athlete = self.protocol.put("/athlete", **params) return model.Athlete.parse_obj( {**raw_athlete, **{"bound_client": self}} ) def get_athlete_koms(self, athlete_id, limit=None): """Gets Q/KOMs/CRs for specified athlete. KOMs are returned as `stravalib.model.SegmentEffort` objects. Parameters ---------- athlete_id : int The ID of the athlete. limit : int Maximum number of KOM segment efforts to return (default unlimited). Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.SegmentEffort` objects. """ result_fetcher = functools.partial( self.protocol.get, "/athletes/{id}/koms", id=athlete_id ) return BatchedResultsIterator( entity=model.SegmentEffort, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_athlete_stats(self, athlete_id=None): """Returns Statistics for the athlete. athlete_id must be the id of the authenticated athlete or left blank. If it is left blank two requests will be made - first to get the authenticated athlete's id and second to get the Stats. https://developers.strava.com/docs/reference/#api-Athletes-getStats Note that this will return the stats for _public_ activities only, regardless of the scopes of the current access token. Parameters ---------- athlete_id : int, default=None Strava ID value for the athlete. Returns ------- py:class:`stravalib.model.AthleteStats` A model containing the Stats """ if athlete_id is None: athlete_id = self.get_athlete().id raw = self.protocol.get("/athletes/{id}/stats", id=athlete_id) # TODO: Better error handling - this will return a 401 if this athlete # is not the authenticated athlete. return model.AthleteStats.parse_obj(raw) def get_athlete_clubs(self): """List the clubs for the currently authenticated athlete. https://developers.strava.com/docs/reference/#api-Clubs-getLoggedInAthleteClubs Returns ------- py:class:`list` A list of :class:`stravalib.model.Club` """ # TODO: This should return a BatchedResultsIterator or otherwise at # most 30 clubs are returned! club_structs = self.protocol.get("/athlete/clubs") return [ model.Club.parse_obj({**raw, **{"bound_client": self}}) for raw in club_structs ] def join_club(self, club_id: int): """Joins the club on behalf of authenticated athlete. (Access token with write permissions required.) Parameters ---------- club_id : int The numeric ID of the club to join. Returns ------- No actual return. This implements a post action that allows the athlete to join a club via an API. """ self.protocol.post("clubs/{id}/join", id=club_id) def leave_club(self, club_id: int): """Leave club on behalf of authenticated user. (Access token with write permissions required.) Parameters ---------- club_id : int Returns ------- No actual return. This implements a post action that allows the athlete to leave a club via an API. """ self.protocol.post("clubs/{id}/leave", id=club_id) def get_club(self, club_id: int): """Return a specific club object. https://developers.strava.com/docs/reference/#api-Clubs-getClubById Parameters ---------- club_id : int The ID of the club to fetch. Returns ------- class: `model.Club` object containing the club data. """ raw = self.protocol.get("/clubs/{id}", id=club_id) return model.Club.parse_obj({**raw, **{"bound_client": self}}) def get_club_members(self, club_id: int, limit=None): """Gets the member objects for specified club ID. https://developers.strava.com/docs/reference/#api-Clubs-getClubMembersById Parameters ---------- club_id : int The numeric ID for the club. limit : int Maximum number of athletes to return. (default unlimited) Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Athlete` objects. """ result_fetcher = functools.partial( self.protocol.get, "/clubs/{id}/members", id=club_id ) return BatchedResultsIterator( entity=model.Athlete, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_club_activities(self, club_id: int, limit=None): """Gets the activities associated with specified club. https://developers.strava.com/docs/reference/#api-Clubs-getClubActivitiesById Parameters ---------- club_id : int The numeric ID for the club. limit : int Maximum number of activities to return. (default unlimited) Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Activity` objects. """ result_fetcher = functools.partial( self.protocol.get, "/clubs/{id}/activities", id=club_id ) return BatchedResultsIterator( entity=model.Activity, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_activity( self, activity_id: int, include_all_efforts: bool = False ): """Gets specified activity. Will be detail-level if owned by authenticated user; otherwise summary-level. https://developers.strava.com/docs/reference/#api-Activities-getActivityById Parameters ---------- activity_id : int The ID of activity to fetch. include_all_efforts : bool, default=False Whether to include segment efforts - only available to the owner of the activity. Returns ------- class: `model.Activity` An Activity object containing the requested activity data. """ raw = self.protocol.get( "/activities/{id}", id=activity_id, include_all_efforts=include_all_efforts, ) return model.Activity.parse_obj({**raw, **{"bound_client": self}}) # TODO: REMOVE from API altogether given deprecation of end point def get_friend_activities(self, limit: int = None): """DEPRECATED This endpoint was removed by Strava in Jan 2018. Parameters ---------- limit : int Maximum number of activities to return. (default unlimited) Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Activity` objects. """ raise NotImplementedError( "The /activities/following endpoint was removed by Strava. " "See https://developers.strava.com/docs/january-2018-update/" ) def create_activity( self, name, activity_type, start_date_local, elapsed_time, description=None, distance=None, ): """Create a new manual activity. If you would like to create an activity from an uploaded GPS file, see the :meth:`stravalib.client.Client.upload_activity` method instead. Parameters ---------- name : str The name of the activity. activity_type : str The activity type (case-insensitive). Possible values: ride, run, swim, workout, hike, walk, nordicski, alpineski, backcountryski, iceskate, inlineskate, kitesurf, rollerski, windsurf, workout, snowboard, snowshoe start_date_local : class:`datetime.datetime` or string in ISO8601 format Local date/time of activity start. (TZ info will be ignored) elapsed_time : class:`datetime.timedelta` or int (seconds) The time in seconds or a :class:`datetime.timedelta` object. description : str, default=None The description for the activity. distance : class:`pint.Quantity` or float (meters), default=None The distance in meters (float) or a :class:`pint.Quantity` instance. """ if isinstance(elapsed_time, timedelta): elapsed_time = elapsed_time.seconds if is_quantity_type(distance): distance = float(unithelper.meters(distance)) if isinstance(start_date_local, datetime): start_date_local = start_date_local.strftime("%Y-%m-%dT%H:%M:%SZ") if not activity_type.lower() in [ t.lower() for t in model.Activity.TYPES ]: raise ValueError( f"Invalid activity type: {activity_type}. Possible values: {model.Activity.TYPES!r}" ) params = dict( name=name, type=activity_type.lower(), start_date_local=start_date_local, elapsed_time=elapsed_time, ) if description is not None: params["description"] = description if distance is not None: params["distance"] = distance raw_activity = self.protocol.post("/activities", **params) return model.Activity.parse_obj( {**raw_activity, **{"bound_client": self}} ) def update_activity( self, activity_id, name=None, activity_type=None, sport_type=None, private=None, commute=None, trainer=None, gear_id=None, description=None, device_name=None, hide_from_home=None, ): """Updates the properties of a specific activity. https://developers.strava.com/docs/reference/#api-Activities-updateActivityById Parameters ---------- activity_id : int The ID of the activity to update. name : str, default=None The name of the activity. activity_type : str, default=None The activity type (case-insensitive). Deprecated. Prefer to use sport_type. In a request where both type and sport_type are present, this field will be ignored. See https://developers.strava.com/docs/reference/#api-models-UpdatableActivity. Possible values: ride, run, swim, workout, hike, walk, nordicski, alpineski, backcountryski, iceskate, inlineskate, kitesurf, rollerski, windsurf, workout, snowboard, snowshoe sport_type : str, default=None Possible values (case-sensitive): AlpineSki, BackcountrySki, Badminton, Canoeing, Crossfit, EBikeRide, Elliptical, EMountainBikeRide, Golf, GravelRide, Handcycle, HighIntensityIntervalTraining, Hike, IceSkate, InlineSkate, Kayaking, Kitesurf, MountainBikeRide, NordicSki, Pickleball, Pilates, Racquetball, Ride, RockClimbing, RollerSki, Rowing, Run, Sail, Skateboard, Snowboard, Snowshoe, Soccer, Squash, StairStepper, StandUpPaddling, Surfing, Swim, TableTennis, Tennis, TrailRun, Velomobile, VirtualRide, VirtualRow, VirtualRun, Walk, WeightTraining, Wheelchair, Windsurf, Workout, Yoga private : bool, default=None Whether the activity is private. .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. commute : bool, default=None Whether the activity is a commute. trainer : bool, default=None Whether this is a trainer activity. gear_id : int, default=None Alpha-numeric ID of gear (bike, shoes) used on this activity. description : str, default=None Description for the activity. device_name : str, default=None Device name for the activity .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. hide_from_home : bool, default=None Whether the activity is muted (hidden from Home and Club feeds). Returns ------- Updates the activity in the selected Strava account """ # Convert the kwargs into a params dict params = {} if name is not None: params["name"] = name if activity_type is not None: if not activity_type.lower() in [ t.lower() for t in model.Activity.TYPES ]: raise ValueError( f"Invalid activity type: {activity_type}. Possible values: {model.Activity.TYPES!r}" ) params["type"] = activity_type.lower() warn_param_deprecation( "activity_type", "sport_type", "https://developers.strava.com/docs/reference/#api-models-UpdatableActivity", ) if sport_type is not None: if not sport_type in model.Activity.SPORT_TYPES: raise ValueError( f"Invalid activity type: {sport_type}. Possible values: {model.Activity.SPORT_TYPES!r}" ) params["sport_type"] = sport_type params.pop( "type", None ) # Just to be sure we don't confuse the Strava API if private is not None: warn_param_unsupported("private") params["private"] = int(private) if commute is not None: params["commute"] = int(commute) if trainer is not None: params["trainer"] = int(trainer) if gear_id is not None: params["gear_id"] = gear_id if description is not None: params["description"] = description if device_name is not None: warn_param_unsupported("device_name") params["device_name"] = device_name if hide_from_home is not None: params["hide_from_home"] = int(hide_from_home) raw_activity = self.protocol.put( "/activities/{activity_id}", activity_id=activity_id, **params ) return model.Activity.parse_obj( {**raw_activity, **{"bound_client": self}} ) def upload_activity( self, activity_file, data_type, name=None, description=None, activity_type=None, private=None, external_id=None, trainer=None, commute=None, ): """Uploads a GPS file (tcx, gpx) to create a new activity for current athlete. https://developers.strava.com/docs/reference/#api-Uploads-createUpload Parameters ---------- activity_file : TextIOWrapper, str or bytes The file object to upload or file contents. data_type : str File format for upload. Possible values: fit, fit.gz, tcx, tcx.gz, gpx, gpx.gz name : str, optional, default=None If not provided, will be populated using start date and location, if available description : str, optional, default=None The description for the activity activity_type : str, optional case-insensitive type of activity. possible values: ride, run, swim, workout, hike, walk, nordicski, alpineski, backcountryski, iceskate, inlineskate, kitesurf, rollerski, windsurf, workout, snowboard, snowshoe Type detected from file overrides, uses athlete's default type if not specified WARNING - This param is supported (as of 2022-11-15), but not documented and may be removed in the future. private : bool, optional, default=None Set to True to mark the resulting activity as private, 'view_private' permissions will be necessary to view the activity. .. deprecated:: 1.0 This param is not supported by the Strava API and may be removed in the future. external_id : str, optional, default=None An arbitrary unique identifier may be specified which will be included in status responses. trainer : bool, optional, default=None Whether the resulting activity should be marked as having been performed on a trainer. commute : bool, optional, default=None Whether the resulting activity should be tagged as a commute. """ if not hasattr(activity_file, "read"): if isinstance(activity_file, str): activity_file = BytesIO(activity_file.encode("utf-8")) elif isinstance(activity_file, bytes): activity_file = BytesIO(activity_file) else: raise TypeError( "Invalid type specified for activity_file: {0}".format( type(activity_file) ) ) valid_data_types = ("fit", "fit.gz", "tcx", "tcx.gz", "gpx", "gpx.gz") if data_type not in valid_data_types: raise ValueError( f"Invalid data type {data_type}. Possible values {valid_data_types!r}" ) params = {"data_type": data_type} if name is not None: params["name"] = name if description is not None: params["description"] = description if activity_type is not None: if not activity_type.lower() in [ t.lower() for t in model.Activity.TYPES ]: raise ValueError( f"Invalid activity type: {activity_type}. Possible values: {model.Activity.TYPES!r}" ) warn_param_unofficial("activity_type") params["activity_type"] = activity_type.lower() if private is not None: warn_param_unsupported("private") params["private"] = int(private) if external_id is not None: params["external_id"] = external_id if trainer is not None: params["trainer"] = int(trainer) if commute is not None: params["commute"] = int(commute) initial_response = self.protocol.post( "/uploads", files={"file": activity_file}, check_for_errors=False, **params, ) return ActivityUploader(self, response=initial_response) def delete_activity(self, activity_id): """Deletes the specified activity. https://developers.strava.com/docs/reference/#api-Activities Parameters ---------- activity_id : int The activity to delete. """ self.protocol.delete("/activities/{id}", id=activity_id) def get_activity_zones(self, activity_id): """Gets zones for activity. Requires premium account. https://developers.strava.com/docs/reference/#api-Activities-getZonesByActivityId Parameters ---------- activity_id : int The activity for which to get zones. Returns ------- py:class:`list` A list of :class:`stravalib.model.BaseActivityZone` objects. """ zones = self.protocol.get("/activities/{id}/zones", id=activity_id) return [ model.BaseActivityZone.parse_obj({**z, **{"bound_client": self}}) for z in zones ] def get_activity_comments(self, activity_id, markdown=False, limit=None): """Gets the comments for an activity. https://developers.strava.com/docs/reference/#api-Activities-getCommentsByActivityId Parameters ---------- activity_id : int The activity for which to fetch comments. markdown : bool Whether to include markdown in comments (default is false/filterout) limit : int Max rows to return (default unlimited). Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.ActivityComment` objects. """ result_fetcher = functools.partial( self.protocol.get, "/activities/{id}/comments", id=activity_id, markdown=int(markdown), ) return BatchedResultsIterator( entity=model.ActivityComment, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_activity_kudos(self, activity_id, limit=None): """Gets the kudos for an activity. https://developers.strava.com/docs/reference/#api-Activities-getKudoersByActivityId Parameters ---------- activity_id : int The activity for which to fetch kudos. limit : int Max rows to return (default unlimited). Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.ActivityKudos` objects. """ result_fetcher = functools.partial( self.protocol.get, "/activities/{id}/kudos", id=activity_id ) return BatchedResultsIterator( entity=model.ActivityKudos, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_activity_photos( self, activity_id, size=None, only_instagram=False ): """Gets the photos from an activity. https://developers.strava.com/docs/reference/#api-Activities Parameters ---------- activity_id : int The activity for which to fetch photos. size : int, default=None the requested size of the activity's photos. URLs for the photos will be returned that best match the requested size. If not included, the smallest size is returned only_instagram : bool, default=False Parameter to preserve legacy behavior of only returning Instagram photos. Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.ActivityPhoto` objects. """ params = {} if not only_instagram: params["photo_sources"] = "true" if size is not None: params["size"] = size result_fetcher = functools.partial( self.protocol.get, "/activities/{id}/photos", id=activity_id, **params, ) return BatchedResultsIterator( entity=model.ActivityPhoto, bind_client=self, result_fetcher=result_fetcher, ) def get_activity_laps(self, activity_id): """Gets the laps from an activity. https://developers.strava.com/docs/reference/#api-Activities-getLapsByActivityId Parameters ---------- activity_id : int The activity for which to fetch laps. Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.ActivityLaps` objects. """ result_fetcher = functools.partial( self.protocol.get, "/activities/{id}/laps", id=activity_id ) return BatchedResultsIterator( entity=model.ActivityLap, bind_client=self, result_fetcher=result_fetcher, ) # TODO remove this method given deprecation of end point def get_related_activities(self, activity_id, limit=None): """Deprecated. This endpoint was removed by strava in Jan 2018. Parameters ---------- activity_id : int The activity for which to fetch related activities. limit : int, default=None Rate limit value for getting activities Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Activity` objects. """ raise NotImplementedError( "The /activities/{id}/related endpoint was removed by Strava. " "See https://developers.strava.com/docs/january-2018-update/" ) def get_gear(self, gear_id): """Get details for an item of gear. https://developers.strava.com/docs/reference/#api-Gears Parameters ---------- gear_id : str The gear id. Returns ------- class:`stravalib.model.Gear` The Bike or Shoe subclass object. """ return model.Gear.parse_obj( self.protocol.get("/gear/{id}", id=gear_id) ) def get_segment_effort(self, effort_id): """Return a specific segment effort by ID. https://developers.strava.com/docs/reference/#api-SegmentEfforts Parameters ---------- effort_id : int The id of associated effort to fetch. Returns ------- class:`stravalib.model.SegmentEffort` The specified effort on a segment. """ return model.SegmentEffort.parse_obj( self.protocol.get("/segment_efforts/{id}", id=effort_id) ) def get_segment(self, segment_id): """Gets a specific segment by ID. https://developers.strava.com/docs/reference/#api-SegmentEfforts-getSegmentEffortById Parameters ---------- segment_id : int The segment to fetch. Returns ------- class:`stravalib.model.Segment` A segment object. """ return model.Segment.parse_obj( { **self.protocol.get("/segments/{id}", id=segment_id), **{"bound_client": self}, } ) def get_starred_segments(self, limit=None): """Returns a summary representation of the segments starred by the authenticated user. Pagination is supported. https://developers.strava.com/docs/reference/#api-Segments-getLoggedInAthleteStarredSegments Parameters ---------- limit : int, optional, default=None Limit number of starred segments returned. Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Segment` starred by authenticated user. """ params = {} if limit is not None: params["limit"] = limit result_fetcher = functools.partial( self.protocol.get, "/segments/starred" ) return BatchedResultsIterator( entity=model.Segment, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_athlete_starred_segments(self, athlete_id, limit=None): """Returns a summary representation of the segments starred by the specified athlete. Pagination is supported. https://developers.strava.com/docs/reference/#api-Segments-getLoggedInAthleteStarredSegments Parameters ---------- athlete_id : int The ID of the athlete. limit : int, optional, default=None Limit number of starred segments returned. Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Segment` starred by authenticated user. """ result_fetcher = functools.partial( self.protocol.get, "/athletes/{id}/segments/starred", id=athlete_id ) return BatchedResultsIterator( entity=model.Segment, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_segment_efforts( self, segment_id, athlete_id=None, start_date_local=None, end_date_local=None, limit=None, ): """Gets all efforts on a particular segment sorted by start_date_local Returns an array of segment effort summary representations sorted by start_date_local ascending or by elapsed_time if an athlete_id is provided. If no filtering parameters is provided all efforts for the segment will be returned. Date range filtering is accomplished using an inclusive start and end time, thus start_date_local and end_date_local must be sent together. For open ended ranges pick dates significantly in the past or future. The filtering is done over local time for the segment, so there is no need for timezone conversion. For example, all efforts on Jan. 1st, 2014 for a segment in San Francisco, CA can be fetched using 2014-01-01T00:00:00Z and 2014-01-01T23:59:59Z. https://developers.strava.com/docs/reference/#api-SegmentEfforts-getEffortsBySegmentId Parameters ---------- segment_id : int ID for the segment of interest athlete_id: int, optional ID of athlete. start_date_local : datetime.datetime or str, optional, default=None Efforts before this date will be excluded. Either as ISO8601 or datetime object end_date_local : datetime.datetime or str, optional, default=None Efforts after this date will be excluded. Either as ISO8601 or datetime object limit : int, default=None, optional limit number of efforts. athlete_id : int, default=None Strava ID for the athlete Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.SegmentEffort` efforts on a segment. """ params = {"segment_id": segment_id} if athlete_id is not None: params["athlete_id"] = athlete_id if start_date_local: if isinstance(start_date_local, str): start_date_local = arrow.get(start_date_local).naive params["start_date_local"] = start_date_local.strftime( "%Y-%m-%dT%H:%M:%SZ" ) if end_date_local: if isinstance(end_date_local, str): end_date_local = arrow.get(end_date_local).naive params["end_date_local"] = end_date_local.strftime( "%Y-%m-%dT%H:%M:%SZ" ) if limit is not None: params["limit"] = limit result_fetcher = functools.partial( self.protocol.get, "/segments/{segment_id}/all_efforts", **params ) return BatchedResultsIterator( entity=model.BaseEffort, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def explore_segments( self, bounds, activity_type=None, min_cat=None, max_cat=None ): """Returns an array of up to 10 segments. https://developers.strava.com/docs/reference/#api-Segments-exploreSegments Parameters ---------- bounds : list of 4 floats or list of 2 (lat,lon) tuples list of bounding box corners lat/lon [sw.lat, sw.lng, ne.lat, ne.lng] (south,west,north,east) activity_type : str optional, default is riding) 'running' or 'riding' min_cat : int, optional, default=None Minimum climb category filter max_cat : int, optional, default=None Maximum climb category filter Returns ------- py:class:`list` An list of :class:`stravalib.model.Segment`. """ if len(bounds) == 2: bounds = (bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]) elif len(bounds) != 4: raise ValueError( "Invalid bounds specified: {0!r}. Must be list of 4 float values or list of 2 (lat,lon) tuples." ) params = {"bounds": ",".join(str(b) for b in bounds)} valid_activity_types = ("riding", "running") if activity_type is not None: if activity_type not in ("riding", "running"): raise ValueError( "Invalid activity type: {0}. Possible values: {1!r}".format( activity_type, valid_activity_types ) ) params["activity_type"] = activity_type if min_cat is not None: params["min_cat"] = min_cat if max_cat is not None: params["max_cat"] = max_cat raw = self.protocol.get("/segments/explore", **params) return [ model.SegmentExplorerResult.parse_obj( {**v, **{"bound_client": self}} ) for v in raw["segments"] ] def get_activity_streams( self, activity_id, types=None, resolution=None, series_type=None ): """Returns a stream for an activity. https://developers.strava.com/docs/reference/#api-Streams-getActivityStreams Streams represent the raw spatial data for the uploaded file. External applications may only access this information for activities owned by the authenticated athlete. Streams are available in 11 different types. If the stream is not available for a particular activity it will be left out of the request results. Streams types are: time, latlng, distance, altitude, velocity_smooth, heartrate, cadence, watts, temp, moving, grade_smooth Parameters ---------- activity_id : int The ID of activity. types : list, optional, default=None A list of the types of streams to fetch. resolution : str optional, default is 'all') indicates desired number of data points. 'low' (100), 'medium' (1000), 'high' (10000) or 'all'. series_type : str, optional, default='distance' Relevant only if using resolution either 'time' or 'distance'. Used to index the streams if the stream is being reduced. Returns ------- py:class:`dict` An dictionary of :class:`stravalib.model.Stream` from the activity or None if there are no streams. """ # Stream is a comma separated list if types is not None: types = ",".join(types) params = {} if resolution is not None: params["resolution"] = resolution if series_type is not None: params["series_type"] = series_type result_fetcher = functools.partial( self.protocol.get, "/activities/{id}/streams/{types}".format( id=activity_id, types=types ), **params, ) streams = BatchedResultsIterator( entity=model.Stream, bind_client=self, result_fetcher=result_fetcher, ) # Pack streams into dictionary try: return {i.type: i for i in streams} except exc.ObjectNotFound: return None # just to be explicit. def get_effort_streams( self, effort_id, types=None, resolution=None, series_type=None ): """Returns an streams for an effort. https://developers.strava.com/docs/reference/#api-Streams-getSegmentEffortStreams Streams represent the raw data of the uploaded file. External applications may only access this information for activities owned by the authenticated athlete. Streams are available in 11 different types. If the stream is not available for a particular activity it will be left out of the request results. Streams types are: time, latlng, distance, altitude, velocity_smooth, heartrate, cadence, watts, temp, moving, grade_smooth Parameters ---------- effort_id : int The ID of effort. types : list, optional, default=None A list of the the types of streams to fetch. resolution : str, optional, default='all' Indicates desired number of data points. 'low' (100), 'medium' (1000), 'high' (10000) or 'all'. series_type : str, optional, default='distance' Relevant only if using resolution either 'time' or 'distance'. Used to index the streams if the stream is being reduced. Returns ------- py:class:`dict` An dictionary of :class:`stravalib.model.Stream` from the effort. """ # Stream are comma separated lists if types is not None: types = ",".join(types) params = {} if resolution is not None: params["resolution"] = resolution if series_type is not None: params["series_type"] = series_type result_fetcher = functools.partial( self.protocol.get, "/segment_efforts/{id}/streams/{types}".format( id=effort_id, types=types ), **params, ) streams = BatchedResultsIterator( entity=model.Stream, bind_client=self, result_fetcher=result_fetcher, ) # Pack streams into dictionary return {i.type: i for i in streams} def get_segment_streams( self, segment_id, types=None, resolution=None, series_type=None ): """Returns an streams for a segment. https://developers.strava.com/docs/reference/#api-Streams-getSegmentStreams Streams represent the raw data of the uploaded file. External applications may only access this information for activities owned by the authenticated athlete. Streams are available in 11 different types. If the stream is not available for a particular activity it will be left out of the request results. Streams types are: time, latlng, distance, altitude, velocity_smooth, heartrate, cadence, watts, temp, moving, grade_smooth Parameters ---------- segment_id : int The ID of segment. types : list, optional, default=None A list of the the types of streams to fetch. resolution : str, optional, default='all' Indicates desired number of data points. 'low' (100), 'medium' (1000), 'high' (10000) or 'all'. series_type : str, optional, default='distance' Relevant only if using resolution either 'time' or 'distance'. Used to index the streams if the stream is being reduced. Returns ------- py:class:`dict` An dictionary of :class:`stravalib.model.Stream` from the effort. """ # Stream are comma separated lists if types is not None: types = ",".join(types) params = {} if resolution is not None: params["resolution"] = resolution if series_type is not None: params["series_type"] = series_type result_fetcher = functools.partial( self.protocol.get, "/segments/{id}/streams/{types}".format( id=segment_id, types=types ), **params, ) streams = BatchedResultsIterator( entity=model.Stream, bind_client=self, result_fetcher=result_fetcher, ) # Pack streams into dictionary return {i.type: i for i in streams} def get_routes(self, athlete_id=None, limit=None): """Gets the routes list for an authenticated user. https://developers.strava.com/docs/reference/#api-Routes-getRoutesByAthleteId Parameters ---------- athlete_id : int, default=None Strava athlete ID limit : int, default=unlimited Max rows to return. Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Route` objects. """ if athlete_id is None: athlete_id = self.get_athlete().id result_fetcher = functools.partial( self.protocol.get, "/athletes/{id}/routes".format(id=athlete_id) ) return BatchedResultsIterator( entity=model.Route, bind_client=self, result_fetcher=result_fetcher, limit=limit, ) def get_route(self, route_id): """Gets specified route. Will be detail-level if owned by authenticated user; otherwise summary-level. https://developers.strava.com/docs/reference/#api-Routes-getRouteById Parameters ---------- route_id : int The ID of route to fetch. Returns ------- class: `model.Route` A model.Route object containing the route data. """ raw = self.protocol.get("/routes/{id}", id=route_id) return model.Route.parse_obj({**raw, **{"bound_client": self}}) def get_route_streams(self, route_id): """Returns streams for a route. Streams represent the raw data of the saved route. External applications may access this information for all public routes and for the private routes of the authenticated athlete. The 3 available route stream types `distance`, `altitude` and `latlng` are always returned. See: https://developers.strava.com/docs/reference/#api-Streams-getRouteStreams Parameters ---------- route_id : int The ID of activity. Returns ------- py:class:`dict` A dictionary of :class:`stravalib.model.Stream` from the route. """ result_fetcher = functools.partial( self.protocol.get, "/routes/{id}/streams/".format(id=route_id) ) streams = BatchedResultsIterator( entity=model.Stream, bind_client=self, result_fetcher=result_fetcher, ) # Pack streams into dictionary return {i.type: i for i in streams} # TODO: removed old link to create a subscription but can't find new equiv # in current strava docs def create_subscription( self, client_id, client_secret, callback_url, verify_token=model.Subscription.VERIFY_TOKEN_DEFAULT, ): """Creates a webhook event subscription. Parameters ---------- client_id : int application's ID, obtained during registration client_secret : str application's secret, obtained during registration callback_url : str callback URL where Strava will first send a GET request to validate, then subsequently send POST requests with updates verify_token : str a token you can use to verify Strava's GET callback request (Default value = model.Subscription.VERIFY_TOKEN_DEFAULT) Returns ------- class:`stravalib.model.Subscription` Notes ----- `verify_token` is set to a default in the event that the author doesn't want to specify one. The application must have permission to make use of the webhook API. Access can be requested by contacting developers -at- strava.com. An instance of :class:`stravalib.model.Subscription`. """ params = dict( client_id=client_id, client_secret=client_secret, callback_url=callback_url, verify_token=verify_token, ) raw = self.protocol.post("/push_subscriptions", **params) return model.Subscription.parse_obj({**raw, **{"bound_client": self}}) # TODO: UPDATE - this method uses (de)serialize which is deprecated def handle_subscription_callback( self, raw, verify_token=model.Subscription.VERIFY_TOKEN_DEFAULT ): """Validate callback request and return valid response with challenge. Parameters ---------- raw : dict The raw JSON response which will be serialized to a Python dict. verify_token : default=model.Subscription.VERIFY_TOKEN_DEFAULT Returns ------- Dict[str, str] The JSON response expected by Strava to the challenge request. """ callback = model.SubscriptionCallback.deserialize(raw) callback.validate(verify_token) response_raw = {"hub.challenge": callback.hub_challenge} return response_raw # TODO: i'm not sure what raw's "type" is here def handle_subscription_update(self, raw): """Converts a raw subscription update into a model. Parameters ---------- raw : dict The raw json response deserialized into a dict. Returns ------- class:`stravalib.model.SubscriptionUpdate` The subscription update model object. """ return model.SubscriptionUpdate.parse_obj( {**raw, **{"bound_client": self}} ) def list_subscriptions(self, client_id, client_secret): """List current webhook event subscriptions in place for the current application. Parameters ---------- client_id : int application's ID, obtained during registration client_secret : str application's secret, obtained during registration Returns ------- class:`BatchedResultsIterator` An iterator of :class:`stravalib.model.Subscription` objects. """ result_fetcher = functools.partial( self.protocol.get, "/push_subscriptions", client_id=client_id, client_secret=client_secret, ) return BatchedResultsIterator( entity=model.Subscription, bind_client=self, result_fetcher=result_fetcher, ) def delete_subscription(self, subscription_id, client_id, client_secret): """Unsubscribe from webhook events for an existing subscription. Parameters ---------- subscription_id : int ID of subscription to remove. client_id : int application's ID, obtained during registration client_secret : str application's secret, obtained during registration Returns ------- Deletes the specific subscription using the subscription ID """ self.protocol.delete( "/push_subscriptions/{id}", id=subscription_id, client_id=client_id, client_secret=client_secret, ) # Expects a 204 response if all goes well. class BatchedResultsIterator(object): """An iterator that enables iterating over requests that return paged results.""" # Number of results returned in a batch. We maximize this to minimize # requests to server (rate limiting) default_per_page = 200 def __init__( self, entity, result_fetcher, bind_client=None, limit=None, per_page=None, ): """ Parameters ---------- entity : type The class for the model entity. result_fetcher: callable The callable that will return another batch of results. bind_client: :class:`stravalib.client.Client` The client object to pass to the entities for supporting further fetching of objects. limit: int The maximum number of rides to return. per_page: int How many rows to fetch per page (default is 200). """ self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) self.entity = entity self.bind_client = bind_client self.result_fetcher = result_fetcher self.limit = limit if per_page is not None: self.per_page = per_page else: self.per_page = self.default_per_page self.reset() def __repr__(self): return "<{0} entity={1}>".format( self.__class__.__name__, self.entity.__name__ ) def reset(self): """ """ self._counter = 0 self._buffer = None self._page = 1 self._all_results_fetched = False def _fill_buffer(self): """Fills the internal buffer from Strava API.""" # If we cannot fetch anymore from the server then we're done here. if self._all_results_fetched: self._eof() raw_results = self.result_fetcher( page=self._page, per_page=self.per_page ) entities = [] for raw in raw_results: try: new_entity = self.entity.parse_obj( {**raw, **{"bound_client": self.bind_client}} ) except AttributeError: # Entity doesn't have a parse_obj() method, so must be of a # legacy type new_entity = self.entity.deserialize( raw, bind_client=self.bind_client ) entities.append(new_entity) self._buffer = collections.deque(entities) self.log.debug( "Requested page {0} (got: {1} items)".format( self._page, len(self._buffer) ) ) if len(self._buffer) < self.per_page: self._all_results_fetched = True self._page += 1 def __iter__(self): return self def _eof(self): """ """ self.reset() raise StopIteration def __next__(self): return self.next() def next(self): """ """ if self.limit and self._counter >= self.limit: self._eof() if not self._buffer: self._fill_buffer() try: result = self._buffer.popleft() except IndexError: self._eof() else: self._counter += 1 return result class ActivityUploader(object): """ The "future" object that holds information about an activity file upload and can wait for upload to finish, etc. """ def __init__(self, client, response, raise_exc=True): """ client: `stravalib.client.Client` The :class:`stravalib.client.Client` object that is handling the upload. response: Dict[str,Any] The initial upload response. raise_exc: bool Whether to raise an exception if the response indicates an error state. (default True) """ self.client = client self.response = response self.update_from_response(response, raise_exc=raise_exc) @property def photo_metadata(self): """photo metadata for the activity upload response, if any. it contains a pre-signed uri for uploading the photo. Notes ----- * This is only available after the upload has completed. * This metadata is only available for partner apps. If you have a regular / non partner related Strava app / account it will not work. """ warn_attribute_unofficial("photo_metadata") return self._photo_metadata @photo_metadata.setter def photo_metadata(self, value): """ Parameters ---------- value : list of dictionaries or none Contains an optional list of dictionaries with photo metadata or a value of `None`. Returns ------- Updates the `_photo_metadata` value in the object """ self._photo_metadata = value def update_from_response(self, response, raise_exc=True): """Updates internal state of object. Parameters ---------- response : py:class:`dict` The response object (dict). raise_exc : bool Raises ------ stravalib.exc.ActivityUploadFailed If the response indicates an error and raise_exc is True. Whether to raise an exception if the response indicates an error state. (default True) Returns ------- """ self.upload_id = response.get("id") self.external_id = response.get("external_id") self.activity_id = response.get("activity_id") self.status = response.get("status") or response.get("message") # Undocumented field, it contains pre-signed uri to upload photo to self._photo_metadata: Optional[List[Dict]] = response.get( "photo_metadata" ) if response.get("error"): self.error = response.get("error") elif response.get("errors"): # This appears to be an undocumented API; ths is a bit of a hack # for now. self.error = str(response.get("errors")) else: self.error = None if raise_exc: self.raise_for_error() @property def is_processing(self): """ """ return self.activity_id is None and self.error is None @property def is_error(self): """ """ return self.error is not None @property def is_complete(self): """ """ return self.activity_id is not None def raise_for_error(self): """ """ # FIXME: We need better handling of the actual responses, once those are # more accurately documented. if self.error: raise exc.ActivityUploadFailed(self.error) elif self.status == "The created activity has been deleted.": raise exc.CreatedActivityDeleted(self.status) def poll(self): """Update internal state from polling strava.com. Raises ------- class: `stravalib.exc.ActivityUploadFailed` If poll returns an error. """ response = self.client.protocol.get( "/uploads/{upload_id}", upload_id=self.upload_id, check_for_errors=False, ) self.update_from_response(response) def wait(self, timeout=None, poll_interval=1.0): """Wait for the upload to complete or to err out. Will return the resulting Activity or raise an exception if the upload fails. Parameters ---------- timeout : float, default=None The max seconds to wait. Will raise TimeoutExceeded exception if this time passes without success or error response. poll_interval : float, default=1.0 (seconds) How long to wait between upload checks. Strava recommends 1s minimum. Returns ------- class:`stravalib.model.Activity` Raises ------ stravalib.exc.TimeoutExceeded If a timeout was specified and activity is still processing after timeout has elapsed. stravalib.exc.ActivityUploadFailed If the poll returns an error. The uploaded Activity object (fetched from server) """ start = time.time() while self.activity_id is None: self.poll() time.sleep(poll_interval) if timeout and (time.time() - start) > timeout: raise exc.TimeoutExceeded() # If we got this far, we must have an activity! return self.client.get_activity(self.activity_id) def upload_photo(self, photo, timeout=None): """Uploads a photo to the activity. Parameters ---------- photo : bytes The file-like object to upload. timeout : float, default=None The max seconds to wait. Will raise TimeoutExceeded Notes ----- In order to upload a photo, the activity must be uploaded and processed. The ability to add photos to activity is currently limited to partner apps & devices such as Zwift, Peloton, Tempo Move, etc... Given that the ability isn't in the public API, neither are the docs """ warn_method_unofficial("upload_photo") try: if not isinstance(photo, bytes): raise TypeError("Photo must be bytes type") self.poll() if self.is_processing: raise ValueError("Activity upload not complete") if not self.photo_metadata: raise ActivityPhotoUploadNotSupported( "Photo upload not supported" ) photos_data: List[Dict] = [ photo_data for photo_data in self.photo_metadata if photo_data and photo_data.get("method") == "PUT" and photo_data.get("header", {}).get("Content-Type") == "image/jpeg" ] if not photos_data: raise ActivityPhotoUploadNotSupported( "Photo upload not supported" ) if photos_data: response = self.client.protocol.rsession.put( url=photos_data[0]["uri"], data=photo, headers=photos_data[0]["header"], timeout=timeout, ) response.raise_for_status() except Exception as error: raise exc.ActivityPhotoUploadFailed(error) stravalib-1.3.0/stravalib/exc.py000066400000000000000000000105671442076457100166450ustar00rootroot00000000000000""" Exceptions & Error Handling ============================ Exceptions and error handling for stravalib. These are classes designed to capture and handle various errors encountered when interacting with the Strava API. """ import logging import warnings from typing import Type import requests.exceptions class AuthError(RuntimeError): pass class LoginFailed(AuthError): pass class LoginRequired(AuthError): """ Login is required to perform specified action. """ class UnboundEntity(RuntimeError): """ Exception used to indicate that a model Entity is not bound to client instances. """ class Fault(requests.exceptions.HTTPError): """ Container for exceptions raised by the remote server. """ class ObjectNotFound(Fault): """ When we get a 404 back from an API call. """ class AccessUnauthorized(Fault): """ When we get a 401 back from an API call. """ class RateLimitExceeded(RuntimeError): """ Exception raised when the client rate limit has been exceeded. https://developers.strava.com/docs/rate-limits/ """ def __init__(self, msg, timeout=None, limit=None): super(RateLimitExceeded, self).__init__() self.limit = limit self.timeout = timeout class RateLimitTimeout(RateLimitExceeded): """ Exception raised when the client rate limit has been exceeded and the time to clear the limit (timeout) has not yet been reached https://developers.strava.com/docs/rate-limits/ """ class ActivityUploadFailed(RuntimeError): pass class ErrorProcessingActivity(ActivityUploadFailed): pass class CreatedActivityDeleted(ActivityUploadFailed): pass class ActivityPhotoUploadFailed(RuntimeError): pass class ActivityPhotoUploadNotSupported(ActivityPhotoUploadFailed): pass class TimeoutExceeded(RuntimeError): pass class NotAuthenticatedAthlete(AuthError): """ Exception when trying to access data which requires an authenticated athlete """ pass # Warnings configuration and helper functions warnings.simplefilter("default") logging.captureWarnings(True) def warn_method_deprecation( klass: Type, method_name: str, alternative: str, alt_url: str = None ): alt_support_msg = ( f" See {alt_url} for more information." if alt_url else "" ) warnings.warn( f'The method "{method_name}" of class "{klass}" is deprecated and will be ' f'removed in the future. Instead, you can use "{alternative}".{alt_support_msg}', DeprecationWarning, stacklevel=3, ) def warn_param_unsupported(param_name: str): warnings.warn( f'The "{param_name}" parameter is unsupported by the Strava API. It has no ' "effect and may lead to errors in the future.", DeprecationWarning, stacklevel=3, ) def warn_param_deprecation( param_name: str, alternative: str, alt_url: str = None ): alt_support_msg = ( f" See {alt_url} for more information." if alt_url else "" ) warnings.warn( f'The "{param_name}" parameter is deprecated and will be removed' f'in the future. Instead, you can use "{alternative}".{alt_support_msg}', DeprecationWarning, stacklevel=3, ) def warn_param_unofficial(param_name: str): warnings.warn( f'The "{param_name}" parameter is undocumented in the Strava API. Its use ' "may lead to unexpected behavior or errors in the future.", FutureWarning, stacklevel=3, ) def warn_attribute_unofficial(attr_name: str): warnings.warn( f'The "{attr_name}" parameter is undocumented in the Strava API. Its use ' "may lead to unexpected behavior or errors in the future.", FutureWarning, stacklevel=3, ) def warn_method_unofficial(method_name: str): warnings.warn( f'The "{method_name}" method is undocumented in the Strava API. Its use ' "may lead to unexpected behavior or errors in the future.", FutureWarning, stacklevel=3, ) def warn_units_deprecated(): warnings.warn( "You are using a Quantity object or attributes from the units library, which is " "deprecated. Support for these types will be removed in the future. Instead, " "please use Quantity objects from the Pint package (https://pint.readthedocs.io).", DeprecationWarning, stacklevel=3, ) stravalib-1.3.0/stravalib/field_conversions.py000066400000000000000000000033351442076457100215740ustar00rootroot00000000000000import logging from datetime import timedelta from functools import wraps from typing import Any, Callable, List, Optional, Sequence, Union import pytz from pytz.exceptions import UnknownTimeZoneError from stravalib.strava_model import ActivityType, SportType LOGGER = logging.getLogger(__name__) def optional_input(field_conversion_fn: Callable) -> Callable: @wraps(field_conversion_fn) def fn_wrapper(field_value: Any): if field_value is not None: return field_conversion_fn(field_value) else: return None return fn_wrapper @optional_input def enum_value(v: Union[ActivityType, SportType]) -> str: try: return v.__root__ except AttributeError: LOGGER.warning( f"{v} is not an enum, returning itself instead of its value" ) return v @optional_input def enum_values(enums: Sequence[Union[ActivityType, SportType]]) -> List: # Pydantic (1.x) has config for using enum values, but unfortunately # it doesn't work for lists of enums. # See https://github.com/pydantic/pydantic/issues/5005 return [enum_value(e) for e in enums] @optional_input def time_interval(seconds: int) -> timedelta: """ Replaces legacy TimeIntervalAttribute """ return timedelta(seconds=seconds) @optional_input def timezone(tz: str) -> Optional[pytz.timezone]: if " " in tz: # (GMT-08:00) America/Los_Angeles tzname = tz.split(" ", 1)[1] else: # America/Los_Angeles tzname = tz try: tz = pytz.timezone(tzname) except UnknownTimeZoneError as e: LOGGER.warning( f"Encountered unknown time zone {tzname}, returning None" ) tz = None return tz stravalib-1.3.0/stravalib/model.py000066400000000000000000000645321442076457100171670ustar00rootroot00000000000000""" ============================== Model Functions and Classes ============================== This module contains entity classes for representing the various Strava datatypes, such as Activity, Gear, and more. These entities inherit fields from superclasses in `strava_model.py`, which is generated from the official Strava API specification. The classes in this module add behavior such as type enrichment, unit conversion, and lazy loading related entities from the API. """ from __future__ import annotations import logging from datetime import date, datetime from functools import wraps from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union, get_args from pydantic import BaseModel, Field, root_validator, validator from pydantic.datetime_parse import parse_datetime from stravalib import exc from stravalib import unithelper as uh from stravalib.exc import warn_method_deprecation from stravalib.field_conversions import enum_value, enum_values, time_interval, timezone from stravalib.strava_model import ( ActivityStats, ActivityTotal, ActivityType, ActivityZone, BaseStream, Comment, DetailedActivity, DetailedAthlete, DetailedClub, DetailedGear, DetailedSegment, DetailedSegmentEffort, ExplorerSegment, Lap, LatLng, PhotosSummary, PolylineMap, Primary, Route, Split, SportType, SummaryPRSegmentEffort, SummarySegmentEffort, TimedZoneRange, ) LOGGER = logging.getLogger(__name__) def lazy_property(fn): """ Should be used to decorate the functions that return a lazily loaded entity (collection), e.g., the members of a club. Assumes that fn (like a regular getter property) has as single argument a reference to self, and uses one of the (bound) client methods to retrieve an entity (collection) by self.id. """ @wraps(fn) def wrapper(obj): try: if obj.bound_client is None: raise exc.UnboundEntity( f"Unable to fetch objects for unbound {obj.__class__} entity." ) if obj.id is None: LOGGER.warning( f"Cannot retrieve {obj.__class__}.{fn.__name__}, self.id is None" ) return None return fn(obj) except AttributeError as e: raise exc.UnboundEntity( f"Unable to fetch objects for unbound {obj.__class__} entity: {e}" ) return property(wrapper) # Custom validators for some edge cases: def check_valid_location( location: Optional[Union[List[float], str]] ) -> Optional[List[float]]: """ Parameters ---------- location : list of float Either a list of x,y floating point values or strings or None (The legacy serialized format is str) Returns -------- list or None Either returns a list of floating point values representing location x,y data or None if empty list is returned from the API. Raises ------ AttributeError If empty list is returned, raises AttributeError """ # Legacy serialized form is str, so in case of attempting to de-serialize # from local storage: try: return [float(l) for l in location.split(",")] except AttributeError: # Location for activities without GPS may be returned as empty list by # Strava return location if location else None def naive_datetime(value: Optional[Any]) -> Optional[datetime]: if value: dt = parse_datetime(value) return dt.replace(tzinfo=None) else: return None class DeprecatedSerializableMixin(BaseModel): """ Provides backward compatibility with legacy BaseEntity """ @classmethod def deserialize(cls, attribute_value_mapping: Dict): """ Creates and returns a new object based on serialized (dict) struct. """ warn_method_deprecation( cls, "deserialize()", "parse_obj()", "https://docs.pydantic.dev/usage/models/#helper-functions", ) return cls.parse_obj(attribute_value_mapping) def from_dict(self, attribute_value_mapping: Dict): """ Deserializes v into self, resetting and ond/or overwriting existing fields """ warn_method_deprecation( self.__class__.__name__, "from_dict()", "parse_obj()", "https://docs.pydantic.dev/usage/models/#helper-functions", ) # Ugly hack is necessary because parse_obj does not behave in-place but returns a new object self.__init__(**self.parse_obj(attribute_value_mapping).dict()) def to_dict(self): """ Returns a dict representation of self """ warn_method_deprecation( self.__class__.__name__, "to_dict()", "dict()", "https://docs.pydantic.dev/usage/exporting_models/", ) return self.dict() class BackwardCompatibilityMixin: """ Mixin that intercepts attribute lookup and raises warnings or modifies return values based on what is defined in the following class attributes: * _field_conversions * _deprecated_fields (TODO) * _unsupported_fields (TODO) """ def __getattribute__(self, attr): value = object.__getattribute__(self, attr) if attr in ["_field_conversions", "bound_client"] or attr.startswith( "_" ): return value try: if attr in self._field_conversions: return self._field_conversions[attr](value) except AttributeError: # Current model class has no field conversions defined pass try: value.bound_client = self.bound_client return value except (AttributeError, ValueError): pass try: for v in value: v.bound_client = self.bound_client return value except (AttributeError, ValueError, TypeError): # TypeError if v is not iterable pass return value class BoundClientEntity(BaseModel): # Using Any as type here to prevent catch-22 between circular import and # pydantic forward-referencing issues "resolved" by PEP-8 violations. # See e.g. https://github.com/pydantic/pydantic/issues/1873 bound_client: Optional[Any] = Field(None, exclude=True) class LatLon(LatLng, BackwardCompatibilityMixin, DeprecatedSerializableMixin): """ Enables backward compatibility for legacy namedtuple """ @root_validator def check_valid_latlng(cls, values): # Strava sometimes returns an empty list in case of activities without GPS return values if values else None @property def lat(self): return self.__root__[0] @property def lon(self): return self.__root__[1] class Club( DetailedClub, DeprecatedSerializableMixin, BackwardCompatibilityMixin, BoundClientEntity, ): # Undocumented attributes: profile: Optional[str] = None description: Optional[str] = None club_type: Optional[str] = None _field_conversions = {"activity_types": enum_values} @lazy_property def members(self): return self.bound_client.get_club_members(self.id) @lazy_property def activities(self): return self.bound_client.get_club_activities(self.id) class Gear( DetailedGear, DeprecatedSerializableMixin, BackwardCompatibilityMixin ): _field_conversions = {"distance": uh.meters} class Bike(Gear): pass class Shoe(Gear): pass class ActivityTotals( ActivityTotal, DeprecatedSerializableMixin, BackwardCompatibilityMixin ): _field_conversions = { "elapsed_time": time_interval, "moving_time": time_interval, "distance": uh.meters, "elevation_gain": uh.meters, } class AthleteStats( ActivityStats, DeprecatedSerializableMixin, BackwardCompatibilityMixin ): """ Rolled-up totals for rides, runs and swims, as shown in an athlete's public profile. Non-public activities are not counted for these totals. """ # field overrides from superclass for type extensions: recent_ride_totals: Optional[ActivityTotals] = None recent_run_totals: Optional[ActivityTotals] = None recent_swim_totals: Optional[ActivityTotals] = None ytd_ride_totals: Optional[ActivityTotals] = None ytd_run_totals: Optional[ActivityTotals] = None ytd_swim_totals: Optional[ActivityTotals] = None all_ride_totals: Optional[ActivityTotals] = None all_run_totals: Optional[ActivityTotals] = None all_swim_totals: Optional[ActivityTotals] = None _field_conversions = { "biggest_ride_distance": uh.meters, "biggest_climb_elevation_gain": uh.meters, } class Athlete( DetailedAthlete, DeprecatedSerializableMixin, BackwardCompatibilityMixin, BoundClientEntity, ): # field overrides from superclass for type extensions: clubs: Optional[List[Club]] = None bikes: Optional[List[Bike]] = None shoes: Optional[List[Shoe]] = None # Undocumented attributes: is_authenticated: Optional[bool] = None athlete_type: Optional[int] = None friend: Optional[str] = None follower: Optional[str] = None approve_followers: Optional[bool] = None badge_type_id: Optional[int] = None mutual_friend_count: Optional[int] = None date_preference: Optional[str] = None email: Optional[str] = None super_user: Optional[bool] = None email_language: Optional[str] = None max_heartrate: Optional[float] = None username: Optional[str] = None description: Optional[str] = None instagram_username: Optional[str] = None offer_in_app_payment: Optional[bool] = None global_privacy: Optional[bool] = None receive_newsletter: Optional[bool] = None email_kom_lost: Optional[bool] = None dateofbirth: Optional[date] = None facebook_sharing_enabled: Optional[bool] = None profile_original: Optional[str] = None premium_expiration_date: Optional[int] = None email_send_follower_notices: Optional[bool] = None plan: Optional[str] = None agreed_to_terms: Optional[str] = None follower_request_count: Optional[int] = None email_facebook_twitter_friend_joins: Optional[bool] = None receive_kudos_emails: Optional[bool] = None receive_follower_feed_emails: Optional[bool] = None receive_comment_emails: Optional[bool] = None sample_race_distance: Optional[int] = None sample_race_time: Optional[int] = None membership: Optional[str] = None admin: Optional[bool] = None owner: Optional[bool] = None subscription_permissions: Optional[list] = None @validator("athlete_type") def to_str_representation(cls, raw_type): # Replaces legacy "ChoicesAttribute" class return {0: "cyclist", 1: "runner"}.get(raw_type) @lazy_property def authenticated_athlete(self): return self.bound_client.get_athlete() @lazy_property def stats(self): """ Returns ------- Associated :class:`stravalib.model.AthleteStats` """ if not self.is_authenticated_athlete(): raise exc.NotAuthenticatedAthlete( "Statistics are only available for the authenticated athlete" ) return self.bound_client.get_athlete_stats(self.id) def is_authenticated_athlete(self): """ Returns ------- bool Whether the athlete is the authenticated athlete (or not). """ if self.is_authenticated is None: if self.resource_state == 3: # If the athlete is in detailed state it must be the authenticated athlete self.is_authenticated = True else: # We need to check this athlete's id matches the authenticated athlete's id authenticated_athlete = self.authenticated_athlete self.is_authenticated = authenticated_athlete.id == self.id return self.is_authenticated class ActivityComment(Comment): # Field overrides from superclass for type extensions: athlete: Optional[Athlete] = None class ActivityPhotoPrimary(Primary): # Undocumented attributes: use_primary_photo: Optional[bool] = None class ActivityPhotoMeta(PhotosSummary): """ The photos structure returned with the activity, not to be confused with the full loaded photos for an activity. """ # field overrides from superclass for type extensions: primary: Optional[ActivityPhotoPrimary] = None # Undocumented attributes: use_primary_photo: Optional[bool] = None class ActivityPhoto(BackwardCompatibilityMixin, DeprecatedSerializableMixin): """ A full photo record attached to an activity. Warning: this entity is undocumented by Strava and there is no official endpoint to retrieve it """ athlete_id: Optional[int] = None activity_id: Optional[int] = None activity_name: Optional[str] = None ref: Optional[str] = None uid: Optional[str] = None unique_id: Optional[str] = None caption: Optional[str] = None type: Optional[str] = None uploaded_at: Optional[datetime] = None created_at: Optional[datetime] = None created_at_local: Optional[datetime] = None location: Optional[LatLon] = None urls: Optional[Dict] = None sizes: Optional[Dict] = None post_id: Optional[int] = None default_photo: Optional[bool] = None source: Optional[int] = None _naive_local = validator("created_at_local", allow_reuse=True)( naive_datetime ) _check_latlng = validator("location", allow_reuse=True, pre=True)( check_valid_location ) def __repr__(self): if self.source == 1: photo_type = "native" idfield = "unique_id" idval = self.unique_id elif self.source == 2: photo_type = "instagram" idfield = "uid" idval = self.uid else: photo_type = "(no type)" idfield = "id" idval = self.id return "<{clz} {type} {idfield}={id}>".format( clz=self.__class__.__name__, type=photo_type, idfield=idfield, id=idval, ) class ActivityKudos(Athlete): """ Activity kudos are a subset of athlete properties. """ pass class ActivityLap( Lap, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): # Field overrides from superclass for type extensions: activity: Optional[Activity] = None athlete: Optional[Athlete] = None # Undocumented attributes: average_watts: Optional[float] = None average_heartrate: Optional[float] = None max_heartrate: Optional[float] = None device_watts: Optional[bool] = None _field_conversions = { "elapsed_time": time_interval, "moving_time": time_interval, "distance": uh.meters, "total_elevation_gain": uh.meters, "average_speed": uh.meters_per_second, "max_speed": uh.meters_per_second, } _naive_local = validator("start_date_local", allow_reuse=True)( naive_datetime ) class Map(PolylineMap): pass class Split(Split, BackwardCompatibilityMixin, DeprecatedSerializableMixin): """ A split -- may be metric or standard units (which has no bearing on the units used in this object, just the binning of values). """ # Undocumented attributes: average_heartrate: Optional[float] = None average_grade_adjusted_speed: Optional[float] = None _field_conversions = { "elapsed_time": time_interval, "moving_time": time_interval, "distance": uh.meters, "elevation_difference": uh.meters, "average_speed": uh.meters_per_second, "average_grade_adjusted_speed": uh.meters_per_second, } class SegmentExplorerResult( ExplorerSegment, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): """ Represents a segment result from the segment explorer feature. (These are not full segment objects, but the segment object can be fetched via the 'segment' property of this object.) """ # Field overrides from superclass for type extensions: start_latlng: Optional[LatLon] = None end_latlng: Optional[LatLon] = None # Undocumented attributes: starred: Optional[bool] = None _field_conversions = {"elev_difference", uh.meters, "distance", uh.meters} _check_latlng = validator( "start_latlng", "end_latlng", allow_reuse=True, pre=True )(check_valid_location) @lazy_property def segment(self): """Associated (full) :class:`stravalib.model.Segment` object.""" return self.bound_client.get_segment(self.id) class AthleteSegmentStats( SummarySegmentEffort, BackwardCompatibilityMixin, DeprecatedSerializableMixin, ): """ A structure being returned for segment stats for current athlete. """ # Undocumented attributes: effort_count: Optional[int] = None pr_elapsed_time: Optional[int] = None pr_date: Optional[date] = None _field_conversions = { "elapsed_time": time_interval, "pr_elapsed_time": time_interval, "distance": uh.meters, } _naive_local = validator("start_date_local", allow_reuse=True)( naive_datetime ) class AthletePrEffort( SummaryPRSegmentEffort, BackwardCompatibilityMixin, DeprecatedSerializableMixin, ): # Undocumented attributes: distance: Optional[float] = None start_date: Optional[datetime] = None start_date_local: Optional[datetime] = None is_kom: Optional[bool] = None _field_conversions = { "pr_elapsed_time": time_interval, "distance": uh.meters, } _naive_local = validator("start_date_local", allow_reuse=True)( naive_datetime ) @property def elapsed_time(self): # For backward compatibility return self.pr_elapsed_time class Segment( DetailedSegment, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): """ Represents a single Strava segment. """ # field overrides from superclass for type extensions: start_latlng: Optional[LatLon] = None end_latlng: Optional[LatLon] = None map: Optional[Map] = None athlete_segment_stats: Optional[AthleteSegmentStats] = None athlete_pr_effort: Optional[AthletePrEffort] = None # Undocumented attributes: start_latitude: Optional[float] = None end_latitude: Optional[float] = None start_longitude: Optional[float] = None end_longitude: Optional[float] = None starred: Optional[bool] = None pr_time: Optional[int] = None starred_date: Optional[datetime] = None elevation_profile: Optional[str] = None _field_conversions = { "distance": uh.meters, "elevation_high": uh.meters, "elevation_low": uh.meters, "total_elevation_gain": uh.meters, "pr_time": time_interval, } _latlng_check = validator( "start_latlng", "end_latlng", allow_reuse=True, pre=True )(check_valid_location) class SegmentEffortAchievement(BaseModel): """ An undocumented structure being returned for segment efforts. """ rank: Optional[int] = None """ Rank in segment (either overall leaderboard, or pr rank) """ type: Optional[str] = None """ The type of achievement -- e.g. 'year_pr' or 'overall' """ type_id: Optional[int] = None """ Numeric ID for type of achievement? (6 = year_pr, 2 = overall ??? other?) """ effort_count: Optional[int] = None class BaseEffort( DetailedSegmentEffort, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): """ Base class for a best effort or segment effort. """ # field overrides from superclass for type extensions: segment: Optional[Segment] = None activity: Optional[Activity] = None athlete: Optional[Athlete] = None _field_conversions = { "moving_time": time_interval, "elapsed_time": time_interval, "distance": uh.meters, } _naive_local = validator("start_date_local", allow_reuse=True)( naive_datetime ) class BestEffort(BaseEffort): """ Class representing a best effort (e.g. best time for 5k) """ class SegmentEffort(BaseEffort): """ Class representing a best effort on a particular segment. """ achievements: Optional[List[SegmentEffortAchievement]] = None class Activity( DetailedActivity, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): """ Represents an activity (ride, run, etc.). """ # field overrides from superclass for type extensions: athlete: Optional[Athlete] = None start_latlng: Optional[LatLon] = None end_latlng: Optional[LatLon] = None map: Optional[Map] = None gear: Optional[Gear] = None best_efforts: Optional[List[BestEffort]] = None segment_efforts: Optional[List[SegmentEffort]] = None splits_metric: Optional[List[Split]] = None splits_standard: Optional[List[Split]] = None photos: Optional[ActivityPhotoMeta] = None laps: Optional[List[ActivityLap]] = None # Added for backward compatibility # TODO maybe deprecate? TYPES: ClassVar[Tuple] = get_args( ActivityType.__fields__["__root__"].type_ ) SPORT_TYPES: ClassVar[Tuple] = get_args( SportType.__fields__["__root__"].type_ ) # Undocumented attributes: guid: Optional[str] = None utc_offset: Optional[float] = None location_city: Optional[str] = None location_state: Optional[str] = None location_country: Optional[str] = None start_latitude: Optional[float] = None start_longitude: Optional[float] = None pr_count: Optional[int] = None suffer_score: Optional[int] = None has_heartrate: Optional[bool] = None average_heartrate: Optional[float] = None max_heartrate: Optional[int] = None average_cadence: Optional[float] = None average_temp: Optional[int] = None instagram_primary_photo: Optional[str] = None partner_logo_url: Optional[str] = None partner_brand_tag: Optional[str] = None from_accepted_tag: Optional[bool] = None segment_leaderboard_opt_out: Optional[bool] = None perceived_exertion: Optional[int] = None _field_conversions = { "moving_time": time_interval, "elapsed_time": time_interval, "timezone": timezone, "distance": uh.meters, "total_elevation_gain": uh.meters, "average_speed": uh.meters_per_second, "max_speed": uh.meters_per_second, "type": enum_value, "sport_type": enum_value, } _latlng_check = validator( "start_latlng", "end_latlng", allow_reuse=True, pre=True )(check_valid_location) _naive_local = validator("start_date_local", allow_reuse=True)( naive_datetime ) @lazy_property def comments(self): return self.bound_client.get_activity_comments(self.id) @lazy_property def zones(self): return self.bound_client.get_activity_zones(self.id) @lazy_property def kudos(self): return self.bound_client.get_activity_kudos(self.id) @lazy_property def full_photos(self): return self.bound_client.get_activity_photos( self.id, only_instagram=False ) class DistributionBucket(TimedZoneRange): """ A single distribution bucket object, used for activity zones. """ _field_conversions = {"time": uh.seconds} class BaseActivityZone( ActivityZone, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): """ Base class for activity zones. A collection of :class:`stravalib.model.DistributionBucket` objects. """ # field overrides from superclass for type extensions: distribution_buckets: Optional[List[DistributionBucket]] = None # overriding the superclass type: it should also support pace as value type: Optional[Literal["heartrate", "power", "pace"]] = None class Stream( BaseStream, BackwardCompatibilityMixin, DeprecatedSerializableMixin ): """ Stream of readings from the activity, effort or segment. """ type: Optional[str] = None # Not using the typed subclasses from the generated model # for backward compatibility: data: Optional[List[Any]] = None class Route( Route, BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity, ): """ Represents a Route. """ # Superclass field overrides for using extended types athlete: Optional[Athlete] = None map: Optional[Map] = None segments: Optional[List[Segment]] _field_conversions = {"distance": uh.meters, "elevation_gain": uh.meters} class Subscription( BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity ): """ Represents a Webhook Event Subscription. """ OBJECT_TYPE_ACTIVITY: ClassVar[str] = "activity" ASPECT_TYPE_CREATE: ClassVar[str] = "create" VERIFY_TOKEN_DEFAULT: ClassVar[str] = "STRAVA" id: Optional[int] = None application_id: Optional[int] = None object_type: Optional[str] = None aspect_type: Optional[str] = None callback_url: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class SubscriptionCallback( BackwardCompatibilityMixin, DeprecatedSerializableMixin ): """ Represents a Webhook Event Subscription Callback. """ hub_mode: Optional[str] = None hub_verify_token: Optional[str] = None hub_challenge: Optional[str] = None class Config: alias_generator = lambda field_name: field_name.replace("hub_", "hub.") def validate(self, verify_token=Subscription.VERIFY_TOKEN_DEFAULT): assert self.hub_verify_token == verify_token class SubscriptionUpdate( BackwardCompatibilityMixin, DeprecatedSerializableMixin, BoundClientEntity ): """ Represents a Webhook Event Subscription Update. """ subscription_id: Optional[int] = None owner_id: Optional[int] = None object_id: Optional[int] = None object_type: Optional[str] = None aspect_type: Optional[str] = None event_time: Optional[datetime] = None updates: Optional[Dict] = None SegmentEffort.update_forward_refs() ActivityLap.update_forward_refs() BestEffort.update_forward_refs() stravalib-1.3.0/stravalib/protocol.py000066400000000000000000000321731442076457100177240ustar00rootroot00000000000000""" Protocol ============== Low-level classes for interacting directly with the Strava API webservers. """ import abc import functools import logging from urllib.parse import urlencode, urljoin, urlunsplit import requests from stravalib import exc class ApiV3(metaclass=abc.ABCMeta): """ This class is responsible for performing the HTTP requests, rate limiting, and error handling. """ server = "www.strava.com" api_base = "/api/v3" def __init__( self, access_token=None, requests_session=None, rate_limiter=None ): """ Initialize this protocol client, optionally providing a (shared) :class:`requests.Session` object. :param access_token: The token that provides access to a specific Strava account. :type access_token: str :param requests_session: An existing :class:`requests.Session` object to use. :type requests_session::class:`requests.Session` """ self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) self.access_token = access_token if requests_session: self.rsession = requests_session else: self.rsession = requests.Session() if rate_limiter is None: # Make it a dummy function, so we don't have to check if it's defined before # calling it later rate_limiter = lambda x=None: None self.rate_limiter = rate_limiter def authorization_url( self, client_id, redirect_uri, approval_prompt="auto", scope=None, state=None, ): """ Get the URL needed to authorize your application to access a Strava user's information. See https://developers.strava.com/docs/authentication/ :param client_id: The numeric developer client id. :type client_id: int :param redirect_uri: The URL that Strava will redirect to after successful (or failed) authorization. :type redirect_uri: str :param approval_prompt: Whether to prompt for approval even if approval already granted to app. Choices are 'auto' or 'force'. (Default is 'auto') :type approval_prompt: str :param scope: The access scope required. Omit to imply "read" and "activity:read" Valid values are 'read', 'read_all', 'profile:read_all', 'profile:write', 'activity:read', 'activity:read_all', 'activity:write'. :type scope: list[str] :param state: An arbitrary variable that will be returned to your application in the redirect URI. :type state: str :return: The URL to use for authorization link. :rtype: str """ assert approval_prompt in ("auto", "force") if scope is None: scope = ["read", "activity:read"] elif isinstance(scope, (str, bytes)): scope = [scope] unsupported = set(scope) - { "read", "read_all", "profile:read_all", "profile:write", "activity:read", "activity:read_all", "activity:write", } assert not unsupported, "Unsupported scope value(s): {}".format( unsupported ) if isinstance(scope, (list, tuple)): scope = ",".join(scope) params = { "client_id": client_id, "redirect_uri": redirect_uri, "approval_prompt": approval_prompt, "response_type": "code", } if scope is not None: params["scope"] = scope if state is not None: params["state"] = state return urlunsplit( ("https", self.server, "/oauth/authorize", urlencode(params), "") ) def exchange_code_for_token(self, client_id, client_secret, code): """ Exchange the temporary authorization code (returned with redirect from strava authorization URL) for a short-lived access token and a refresh token (used to obtain the next access token later on). :param client_id: The numeric developer client id. :type client_id: int :param client_secret: The developer client secret :type client_secret: str :param code: The temporary authorization code :type code: str :return: Dictionary containing the access_token, refresh_token and expires_at (number of seconds since Epoch when the provided access token will expire) :rtype: dict """ response = self._request( "https://{0}/oauth/token".format(self.server), params={ "client_id": client_id, "client_secret": client_secret, "code": code, "grant_type": "authorization_code", }, method="POST", ) access_info = dict() access_info["access_token"] = response["access_token"] access_info["refresh_token"] = response.get("refresh_token", None) access_info["expires_at"] = response.get("expires_at", None) self.access_token = response["access_token"] return access_info def refresh_access_token(self, client_id, client_secret, refresh_token): """ Exchanges the previous refresh token for a short-lived access token and a new refresh token (used to obtain the next access token later on). :param client_id: The numeric developer client id. :type client_id: int :param client_secret: The developer client secret :type client_secret: str :param refresh_token: The refresh token obtain from a previous authorization request :type refresh_token: str :return: Dictionary containing the access_token, refresh_token and expires_at (number of seconds since Epoch when the provided access token will expire) :rtype: dict """ response = self._request( "https://{0}/oauth/token".format(self.server), params={ "client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token, "grant_type": "refresh_token", }, method="POST", ) access_info = dict() access_info["access_token"] = response["access_token"] access_info["refresh_token"] = response["refresh_token"] access_info["expires_at"] = response["expires_at"] self.access_token = response["access_token"] return access_info def resolve_url(self, url): if not url.startswith("http"): url = urljoin( "https://{0}".format(self.server), self.api_base + "/" + url.strip("/"), ) return url def _request( self, url, params=None, files=None, method="GET", check_for_errors=True ): """ Perform the underlying request, returning the parsed JSON results. :param url: The request URL. :type url: str :param params: Request parameters :type params: Dict[str,Any] :param files: Dictionary of file name to file-like objects. :type files: Dict[str,file] :param method: The request method (GET/POST/etc.) :type method: str :param check_for_errors: Whether to raise :type check_for_errors: bool :return: The parsed JSON response. :rtype: Dict[str,Any] """ url = self.resolve_url(url) self.log.info( "{method} {url!r} with params {params!r}".format( method=method, url=url, params=params ) ) if params is None: params = {} if self.access_token: params["access_token"] = self.access_token methods = { "GET": self.rsession.get, "POST": functools.partial(self.rsession.post, files=files), "PUT": self.rsession.put, "DELETE": self.rsession.delete, } try: requester = methods[method.upper()] except KeyError: raise ValueError( "Invalid/unsupported request method specified: {0}".format( method ) ) raw = requester(url, params=params) # Rate limits are taken from HTTP response headers # https://developers.strava.com/docs/rate-limits/ self.rate_limiter(raw.headers) if check_for_errors: self._handle_protocol_error(raw) # 204 = No content if raw.status_code in [204]: resp = {} else: resp = raw.json() return resp def _handle_protocol_error(self, response): """ Parses the raw response from the server, raising a :class:`stravalib.exc.Fault` if the server returned an error. :param response: The response object. :raises Fault: If the response contains an error. """ error_str = None try: json_response = response.json() except ValueError: pass else: if "message" in json_response or "errors" in json_response: error_str = "{0}: {1}".format( json_response.get("message", "Undefined error"), json_response.get("errors"), ) # Special subclasses for some errors msg = None exc_class = None if response.status_code == 404: msg = "%s: %s" % (response.reason, error_str) exc_class = exc.ObjectNotFound elif response.status_code == 401: msg = "%s: %s" % (response.reason, error_str) exc_class = exc.AccessUnauthorized elif 400 <= response.status_code < 500: msg = "%s Client Error: %s [%s]" % ( response.status_code, response.reason, error_str, ) exc_class = exc.Fault elif 500 <= response.status_code < 600: msg = "%s Server Error: %s [%s]" % ( response.status_code, response.reason, error_str, ) exc_class = exc.Fault elif error_str: msg = error_str exc_class = exc.Fault if exc_class is not None: raise exc_class(msg, response=response) return response def _extract_referenced_vars(self, s): """ Utility method to find the referenced format variables in a string. (Assumes string.format() format vars.) :param s: The string that contains format variables. (e.g. "{foo}-text") :return: The list of referenced variable names. (e.g. ['foo']) :rtype: list """ d = {} while True: try: s.format(**d) except KeyError as exc: # exc.args[0] contains the name of the key that was not found; # 0 is used because it appears to work with all types of placeholders. d[exc.args[0]] = 0 else: break return d.keys() def get(self, url, check_for_errors=True, **kwargs): """ Performs a generic GET request for specified params, returning the response. """ referenced = self._extract_referenced_vars(url) url = url.format(**kwargs) params = dict( [(k, v) for k, v in kwargs.items() if not k in referenced] ) return self._request( url, params=params, check_for_errors=check_for_errors ) def post(self, url, files=None, check_for_errors=True, **kwargs): """ Performs a generic POST request for specified params, returning the response. """ referenced = self._extract_referenced_vars(url) url = url.format(**kwargs) params = dict( [(k, v) for k, v in kwargs.items() if not k in referenced] ) return self._request( url, params=params, files=files, method="POST", check_for_errors=check_for_errors, ) def put(self, url, check_for_errors=True, **kwargs): """ Performs a generic PUT request for specified params, returning the response. """ referenced = self._extract_referenced_vars(url) url = url.format(**kwargs) params = dict( [(k, v) for k, v in kwargs.items() if not k in referenced] ) return self._request( url, params=params, method="PUT", check_for_errors=check_for_errors ) def delete(self, url, check_for_errors=True, **kwargs): """ Performs a generic DELETE request for specified params, returning the response. """ referenced = self._extract_referenced_vars(url) url = url.format(**kwargs) params = dict( [(k, v) for k, v in kwargs.items() if not k in referenced] ) return self._request( url, params=params, method="DELETE", check_for_errors=check_for_errors, ) stravalib-1.3.0/stravalib/strava_model.py000066400000000000000000001063121442076457100205400ustar00rootroot00000000000000# generated by datamodel-codegen: # filename: from __future__ import annotations from datetime import datetime from typing import Dict, List, Literal, Optional from pydantic import BaseModel, Field, conint class ActivityTotal(BaseModel): """ A roll-up of metrics pertaining to a set of activities. Values are in seconds and meters. """ achievement_count: Optional[int] = None """ The total number of achievements of the considered activities. """ count: Optional[int] = None """ The number of activities considered in this total. """ distance: Optional[float] = None """ The total distance covered by the considered activities. """ elapsed_time: Optional[int] = None """ The total elapsed time of the considered activities. """ elevation_gain: Optional[float] = None """ The total elevation gain of the considered activities. """ moving_time: Optional[int] = None """ The total moving time of the considered activities. """ class ActivityType(BaseModel): __root__: Literal[ "AlpineSki", "BackcountrySki", "Canoeing", "Crossfit", "EBikeRide", "Elliptical", "Golf", "Handcycle", "Hike", "IceSkate", "InlineSkate", "Kayaking", "Kitesurf", "NordicSki", "Ride", "RockClimbing", "RollerSki", "Rowing", "Run", "Sail", "Skateboard", "Snowboard", "Snowshoe", "Soccer", "StairStepper", "StandUpPaddling", "Surfing", "Swim", "Velomobile", "VirtualRide", "VirtualRun", "Walk", "WeightTraining", "Wheelchair", "Windsurf", "Workout", "Yoga", ] """ An enumeration of the types an activity may have. Note that this enumeration does not include new sport types (e.g. MountainBikeRide, EMountainBikeRide), activities with these sport types will have the corresponding activity type (e.g. Ride for MountainBikeRide, EBikeRide for EMountainBikeRide) """ class BaseStream(BaseModel): original_size: Optional[int] = None """ The number of data points in this stream """ resolution: Optional[Literal["low", "medium", "high"]] = None """ The level of detail (sampling) in which this stream was returned """ series_type: Optional[Literal["distance", "time"]] = None """ The base series used in the case the stream was downsampled """ class CadenceStream(BaseStream): data: Optional[List[int]] = None """ The sequence of cadence values for this stream, in rotations per minute """ class ClubAthlete(BaseModel): admin: Optional[bool] = None """ Whether the athlete is a club admin. """ firstname: Optional[str] = None """ The athlete's first name. """ lastname: Optional[str] = None """ The athlete's last initial. """ member: Optional[str] = None """ The athlete's member status. """ owner: Optional[bool] = None """ Whether the athlete is club owner. """ resource_state: Optional[int] = None """ Resource state, indicates level of detail. Possible values: 1 -> "meta", 2 -> "summary", 3 -> "detail" """ class DistanceStream(BaseStream): data: Optional[List[float]] = None """ The sequence of distance values for this stream, in meters """ class Error(BaseModel): code: Optional[str] = None """ The code associated with this error. """ field: Optional[str] = None """ The specific field or aspect of the resource associated with this error. """ resource: Optional[str] = None """ The type of resource associated with this error. """ class Fault(BaseModel): """ Encapsulates the errors that may be returned from the API. """ errors: Optional[List[Error]] = None """ The set of specific errors associated with this fault, if any. """ message: Optional[str] = None """ The message of the fault. """ class HeartrateStream(BaseStream): data: Optional[List[int]] = None """ The sequence of heart rate values for this stream, in beats per minute """ class LatLng(BaseModel): """ A pair of latitude/longitude coordinates, represented as an array of 2 floating point numbers. """ __root__: List[float] = Field(..., max_items=2, min_items=2) """ A pair of latitude/longitude coordinates, represented as an array of 2 floating point numbers. """ class LatLngStream(BaseStream): data: Optional[List[LatLng]] = None """ The sequence of lat/long values for this stream """ class MembershipApplication(BaseModel): active: Optional[bool] = None """ Whether the membership is currently active """ membership: Optional[Literal["member", "pending"]] = None """ The membership status of this application """ success: Optional[bool] = None """ Whether the application for membership was successfully submitted """ class MetaActivity(BaseModel): id: Optional[int] = None """ The unique identifier of the activity """ class MetaAthlete(BaseModel): id: Optional[int] = None """ The unique identifier of the athlete """ class MetaClub(BaseModel): id: Optional[int] = None """ The club's unique identifier. """ name: Optional[str] = None """ The club's name. """ resource_state: Optional[int] = None """ Resource state, indicates level of detail. Possible values: 1 -> "meta", 2 -> "summary", 3 -> "detail" """ class MovingStream(BaseStream): data: Optional[List[bool]] = None """ The sequence of moving values for this stream, as boolean values """ class Primary(BaseModel): id: Optional[int] = None source: Optional[int] = None unique_id: Optional[str] = None urls: Optional[Dict[str, str]] = None class PhotosSummary(BaseModel): count: Optional[int] = None """ The number of photos """ primary: Optional[Primary] = None class PolylineMap(BaseModel): id: Optional[str] = None """ The identifier of the map """ polyline: Optional[str] = None """ The polyline of the map, only returned on detailed representation of an object """ summary_polyline: Optional[str] = None """ The summary polyline of the map """ class PowerStream(BaseStream): data: Optional[List[int]] = None """ The sequence of power values for this stream, in watts """ class SmoothGradeStream(BaseStream): data: Optional[List[float]] = None """ The sequence of grade values for this stream, as percents of a grade """ class SmoothVelocityStream(BaseStream): data: Optional[List[float]] = None """ The sequence of velocity values for this stream, in meters per second """ class Split(BaseModel): average_speed: Optional[float] = None """ The average speed of this split, in meters per second """ distance: Optional[float] = None """ The distance of this split, in meters """ elapsed_time: Optional[int] = None """ The elapsed time of this split, in seconds """ elevation_difference: Optional[float] = None """ The elevation difference of this split, in meters """ moving_time: Optional[int] = None """ The moving time of this split, in seconds """ pace_zone: Optional[int] = None """ The pacing zone of this split """ split: Optional[int] = None """ N/A """ class SportType(BaseModel): __root__: Literal[ "AlpineSki", "BackcountrySki", "Badminton", "Canoeing", "Crossfit", "EBikeRide", "Elliptical", "EMountainBikeRide", "Golf", "GravelRide", "Handcycle", "HighIntensityIntervalTraining", "Hike", "IceSkate", "InlineSkate", "Kayaking", "Kitesurf", "MountainBikeRide", "NordicSki", "Pickleball", "Pilates", "Racquetball", "Ride", "RockClimbing", "RollerSki", "Rowing", "Run", "Sail", "Skateboard", "Snowboard", "Snowshoe", "Soccer", "Squash", "StairStepper", "StandUpPaddling", "Surfing", "Swim", "TableTennis", "Tennis", "TrailRun", "Velomobile", "VirtualRide", "VirtualRow", "VirtualRun", "Walk", "WeightTraining", "Wheelchair", "Windsurf", "Workout", "Yoga", ] """ An enumeration of the sport types an activity may have. Distinct from ActivityType in that it has new types (e.g. MountainBikeRide) """ class StreamType(BaseModel): __root__: Literal[ "time", "distance", "latlng", "altitude", "velocity_smooth", "heartrate", "cadence", "watts", "temp", "moving", "grade_smooth", ] """ An enumeration of the supported types of streams. """ class SummaryActivity(MetaActivity): achievement_count: Optional[int] = None """ The number of achievements gained during this activity """ athlete: Optional[MetaAthlete] = None athlete_count: Optional[conint(ge=1)] = None """ The number of athletes for taking part in a group activity """ average_speed: Optional[float] = None """ The activity's average speed, in meters per second """ average_watts: Optional[float] = None """ Average power output in watts during this activity. Rides only """ comment_count: Optional[int] = None """ The number of comments for this activity """ commute: Optional[bool] = None """ Whether this activity is a commute """ device_watts: Optional[bool] = None """ Whether the watts are from a power meter, false if estimated """ distance: Optional[float] = None """ The activity's distance, in meters """ elapsed_time: Optional[int] = None """ The activity's elapsed time, in seconds """ elev_high: Optional[float] = None """ The activity's highest elevation, in meters """ elev_low: Optional[float] = None """ The activity's lowest elevation, in meters """ end_latlng: Optional[LatLng] = None external_id: Optional[str] = None """ The identifier provided at upload time """ flagged: Optional[bool] = None """ Whether this activity is flagged """ gear_id: Optional[str] = None """ The id of the gear for the activity """ has_kudoed: Optional[bool] = None """ Whether the logged-in athlete has kudoed this activity """ hide_from_home: Optional[bool] = None """ Whether the activity is muted """ kilojoules: Optional[float] = None """ The total work done in kilojoules during this activity. Rides only """ kudos_count: Optional[int] = None """ The number of kudos given for this activity """ manual: Optional[bool] = None """ Whether this activity was created manually """ map: Optional[PolylineMap] = None max_speed: Optional[float] = None """ The activity's max speed, in meters per second """ max_watts: Optional[int] = None """ Rides with power meter data only """ moving_time: Optional[int] = None """ The activity's moving time, in seconds """ name: Optional[str] = None """ The name of the activity """ photo_count: Optional[int] = None """ The number of Instagram photos for this activity """ private: Optional[bool] = None """ Whether this activity is private """ sport_type: Optional[SportType] = None start_date: Optional[datetime] = None """ The time at which the activity was started. """ start_date_local: Optional[datetime] = None """ The time at which the activity was started in the local timezone. """ start_latlng: Optional[LatLng] = None timezone: Optional[str] = None """ The timezone of the activity """ total_elevation_gain: Optional[float] = None """ The activity's total elevation gain. """ total_photo_count: Optional[int] = None """ The number of Instagram and Strava photos for this activity """ trainer: Optional[bool] = None """ Whether this activity was recorded on a training machine """ type: Optional[ActivityType] = None """ Deprecated. Prefer to use sport_type """ upload_id: Optional[int] = None """ The identifier of the upload that resulted in this activity """ upload_id_str: Optional[str] = None """ The unique identifier of the upload in string format """ weighted_average_watts: Optional[int] = None """ Similar to Normalized Power. Rides with power meter data only """ workout_type: Optional[int] = None """ The activity's workout type """ class SummaryAthlete(MetaAthlete): city: Optional[str] = None """ The athlete's city. """ country: Optional[str] = None """ The athlete's country. """ created_at: Optional[datetime] = None """ The time at which the athlete was created. """ firstname: Optional[str] = None """ The athlete's first name. """ lastname: Optional[str] = None """ The athlete's last name. """ premium: Optional[bool] = None """ Deprecated. Use summit field instead. Whether the athlete has any Summit subscription. """ profile: Optional[str] = None """ URL to a 124x124 pixel profile picture. """ profile_medium: Optional[str] = None """ URL to a 62x62 pixel profile picture. """ resource_state: Optional[int] = None """ Resource state, indicates level of detail. Possible values: 1 -> "meta", 2 -> "summary", 3 -> "detail" """ sex: Optional[Literal["M", "F"]] = None """ The athlete's sex. """ state: Optional[str] = None """ The athlete's state or geographical region. """ summit: Optional[bool] = None """ Whether the athlete has any Summit subscription. """ updated_at: Optional[datetime] = None """ The time at which the athlete was last updated. """ class SummaryClub(MetaClub): activity_types: Optional[List[ActivityType]] = None """ The activity types that count for a club. This takes precedence over sport_type. """ city: Optional[str] = None """ The club's city. """ country: Optional[str] = None """ The club's country. """ cover_photo: Optional[str] = None """ URL to a ~1185x580 pixel cover photo. """ cover_photo_small: Optional[str] = None """ URL to a ~360x176 pixel cover photo. """ featured: Optional[bool] = None """ Whether the club is featured or not. """ member_count: Optional[int] = None """ The club's member count. """ private: Optional[bool] = None """ Whether the club is private. """ profile_medium: Optional[str] = None """ URL to a 60x60 pixel profile picture. """ sport_type: Optional[ Literal["cycling", "running", "triathlon", "other"] ] = None """ Deprecated. Prefer to use activity_types. """ state: Optional[str] = None """ The club's state or geographical region. """ url: Optional[str] = None """ The club's vanity URL. """ verified: Optional[bool] = None """ Whether the club is verified or not. """ class SummaryGear(BaseModel): distance: Optional[float] = None """ The distance logged with this gear. """ id: Optional[str] = None """ The gear's unique identifier. """ name: Optional[str] = None """ The gear's name. """ primary: Optional[bool] = None """ Whether this gear's is the owner's default one. """ resource_state: Optional[int] = None """ Resource state, indicates level of detail. Possible values: 2 -> "summary", 3 -> "detail" """ class SummaryPRSegmentEffort(BaseModel): effort_count: Optional[int] = None """ Number of efforts by the authenticated athlete on this segment. """ pr_activity_id: Optional[int] = None """ The unique identifier of the activity related to the PR effort. """ pr_date: Optional[datetime] = None """ The time at which the PR effort was started. """ pr_elapsed_time: Optional[int] = None """ The elapsed time ot the PR effort. """ class SummarySegmentEffort(BaseModel): activity_id: Optional[int] = None """ The unique identifier of the activity related to this effort """ distance: Optional[float] = None """ The effort's distance in meters """ elapsed_time: Optional[int] = None """ The effort's elapsed time """ id: Optional[int] = None """ The unique identifier of this effort """ is_kom: Optional[bool] = None """ Whether this effort is the current best on the leaderboard """ start_date: Optional[datetime] = None """ The time at which the effort was started. """ start_date_local: Optional[datetime] = None """ The time at which the effort was started in the local timezone. """ class TemperatureStream(BaseStream): data: Optional[List[int]] = None """ The sequence of temperature values for this stream, in celsius degrees """ class TimeStream(BaseStream): data: Optional[List[int]] = None """ The sequence of time values for this stream, in seconds """ class UpdatableActivity(BaseModel): commute: Optional[bool] = None """ Whether this activity is a commute """ description: Optional[str] = None """ The description of the activity """ gear_id: Optional[str] = None """ Identifier for the gear associated with the activity. ‘none’ clears gear from activity """ hide_from_home: Optional[bool] = None """ Whether this activity is muted """ name: Optional[str] = None """ The name of the activity """ sport_type: Optional[SportType] = None trainer: Optional[bool] = None """ Whether this activity was recorded on a training machine """ type: Optional[ActivityType] = None """ Deprecated. Prefer to use sport_type. In a request where both type and sport_type are present, this field will be ignored """ class Upload(BaseModel): activity_id: Optional[int] = None """ The identifier of the activity this upload resulted into """ error: Optional[str] = None """ The error associated with this upload """ external_id: Optional[str] = None """ The external identifier of the upload """ id: Optional[int] = None """ The unique identifier of the upload """ id_str: Optional[str] = None """ The unique identifier of the upload in string format """ status: Optional[str] = None """ The status of this upload """ class ZoneRange(BaseModel): max: Optional[int] = None """ The maximum value in the range. """ min: Optional[int] = None """ The minimum value in the range. """ class ZoneRanges(BaseModel): __root__: List[ZoneRange] class ActivityStats(BaseModel): """ A set of rolled-up statistics and totals for an athlete """ all_ride_totals: Optional[ActivityTotal] = None """ The all time ride stats for the athlete. """ all_run_totals: Optional[ActivityTotal] = None """ The all time run stats for the athlete. """ all_swim_totals: Optional[ActivityTotal] = None """ The all time swim stats for the athlete. """ biggest_climb_elevation_gain: Optional[float] = None """ The highest climb ridden by the athlete. """ biggest_ride_distance: Optional[float] = None """ The longest distance ridden by the athlete. """ recent_ride_totals: Optional[ActivityTotal] = None """ The recent (last 4 weeks) ride stats for the athlete. """ recent_run_totals: Optional[ActivityTotal] = None """ The recent (last 4 weeks) run stats for the athlete. """ recent_swim_totals: Optional[ActivityTotal] = None """ The recent (last 4 weeks) swim stats for the athlete. """ ytd_ride_totals: Optional[ActivityTotal] = None """ The year to date ride stats for the athlete. """ ytd_run_totals: Optional[ActivityTotal] = None """ The year to date run stats for the athlete. """ ytd_swim_totals: Optional[ActivityTotal] = None """ The year to date swim stats for the athlete. """ class AltitudeStream(BaseStream): data: Optional[List[float]] = None """ The sequence of altitude values for this stream, in meters """ class ClubActivity(BaseModel): athlete: Optional[MetaAthlete] = None distance: Optional[float] = None """ The activity's distance, in meters """ elapsed_time: Optional[int] = None """ The activity's elapsed time, in seconds """ moving_time: Optional[int] = None """ The activity's moving time, in seconds """ name: Optional[str] = None """ The name of the activity """ sport_type: Optional[SportType] = None total_elevation_gain: Optional[float] = None """ The activity's total elevation gain. """ type: Optional[ActivityType] = None """ Deprecated. Prefer to use sport_type """ workout_type: Optional[int] = None """ The activity's workout type """ class ClubAnnouncement(BaseModel): athlete: Optional[SummaryAthlete] = None club_id: Optional[int] = None """ The unique identifier of the club this announcements was made in. """ created_at: Optional[datetime] = None """ The time at which this announcement was created. """ id: Optional[int] = None """ The unique identifier of this announcement. """ message: Optional[str] = None """ The content of this announcement """ class Comment(BaseModel): activity_id: Optional[int] = None """ The identifier of the activity this comment is related to """ athlete: Optional[SummaryAthlete] = None created_at: Optional[datetime] = None """ The time at which this comment was created. """ id: Optional[int] = None """ The unique identifier of this comment """ text: Optional[str] = None """ The content of the comment """ class DetailedAthlete(SummaryAthlete): bikes: Optional[List[SummaryGear]] = None """ The athlete's bikes. """ clubs: Optional[List[SummaryClub]] = None """ The athlete's clubs. """ follower_count: Optional[int] = None """ The athlete's follower count. """ friend_count: Optional[int] = None """ The athlete's friend count. """ ftp: Optional[int] = None """ The athlete's FTP (Functional Threshold Power). """ measurement_preference: Optional[Literal["feet", "meters"]] = None """ The athlete's preferred unit system. """ shoes: Optional[List[SummaryGear]] = None """ The athlete's shoes. """ weight: Optional[float] = None """ The athlete's weight. """ class DetailedClub(SummaryClub): admin: Optional[bool] = None """ Whether the currently logged-in athlete is an administrator of this club. """ following_count: Optional[int] = None """ The number of athletes in the club that the logged-in athlete follows. """ membership: Optional[Literal["member", "pending"]] = None """ The membership status of the logged-in athlete. """ owner: Optional[bool] = None """ Whether the currently logged-in athlete is the owner of this club. """ class DetailedGear(SummaryGear): brand_name: Optional[str] = None """ The gear's brand name. """ description: Optional[str] = None """ The gear's description. """ frame_type: Optional[int] = None """ The gear's frame type (bike only). """ model_name: Optional[str] = None """ The gear's model name. """ class ExplorerSegment(BaseModel): avg_grade: Optional[float] = None """ The segment's average grade, in percents """ climb_category: Optional[conint(ge=0, le=5)] = None """ The category of the climb [0, 5]. Higher is harder ie. 5 is Hors catégorie, 0 is uncategorized in climb_category. If climb_category = 5, climb_category_desc = HC. If climb_category = 2, climb_category_desc = 3. """ climb_category_desc: Optional[ Literal["NC", "4", "3", "2", "1", "HC"] ] = None """ The description for the category of the climb """ distance: Optional[float] = None """ The segment's distance, in meters """ elev_difference: Optional[float] = None """ The segments's evelation difference, in meters """ end_latlng: Optional[LatLng] = None id: Optional[int] = None """ The unique identifier of this segment """ name: Optional[str] = None """ The name of this segment """ points: Optional[str] = None """ The polyline of the segment """ start_latlng: Optional[LatLng] = None class HeartRateZoneRanges(BaseModel): custom_zones: Optional[bool] = None """ Whether the athlete has set their own custom heart rate zones """ zones: Optional[ZoneRanges] = None class Lap(BaseModel): activity: Optional[MetaActivity] = None athlete: Optional[MetaAthlete] = None average_cadence: Optional[float] = None """ The lap's average cadence """ average_speed: Optional[float] = None """ The lap's average speed """ distance: Optional[float] = None """ The lap's distance, in meters """ elapsed_time: Optional[int] = None """ The lap's elapsed time, in seconds """ end_index: Optional[int] = None """ The end index of this effort in its activity's stream """ id: Optional[int] = None """ The unique identifier of this lap """ lap_index: Optional[int] = None """ The index of this lap in the activity it belongs to """ max_speed: Optional[float] = None """ The maximum speed of this lat, in meters per second """ moving_time: Optional[int] = None """ The lap's moving time, in seconds """ name: Optional[str] = None """ The name of the lap """ pace_zone: Optional[int] = None """ The athlete's pace zone during this lap """ split: Optional[int] = None start_date: Optional[datetime] = None """ The time at which the lap was started. """ start_date_local: Optional[datetime] = None """ The time at which the lap was started in the local timezone. """ start_index: Optional[int] = None """ The start index of this effort in its activity's stream """ total_elevation_gain: Optional[float] = None """ The elevation gain of this lap, in meters """ class PowerZoneRanges(BaseModel): zones: Optional[ZoneRanges] = None class StreamSet(BaseModel): altitude: Optional[AltitudeStream] = None cadence: Optional[CadenceStream] = None distance: Optional[DistanceStream] = None grade_smooth: Optional[SmoothGradeStream] = None heartrate: Optional[HeartrateStream] = None latlng: Optional[LatLngStream] = None moving: Optional[MovingStream] = None temp: Optional[TemperatureStream] = None time: Optional[TimeStream] = None velocity_smooth: Optional[SmoothVelocityStream] = None watts: Optional[PowerStream] = None class SummarySegment(BaseModel): activity_type: Optional[Literal["Ride", "Run"]] = None athlete_pr_effort: Optional[SummaryPRSegmentEffort] = None athlete_segment_stats: Optional[SummarySegmentEffort] = None average_grade: Optional[float] = None """ The segment's average grade, in percents """ city: Optional[str] = None """ The segments's city. """ climb_category: Optional[int] = None """ The category of the climb [0, 5]. Higher is harder ie. 5 is Hors catégorie, 0 is uncategorized in climb_category. """ country: Optional[str] = None """ The segment's country. """ distance: Optional[float] = None """ The segment's distance, in meters """ elevation_high: Optional[float] = None """ The segments's highest elevation, in meters """ elevation_low: Optional[float] = None """ The segments's lowest elevation, in meters """ end_latlng: Optional[LatLng] = None id: Optional[int] = None """ The unique identifier of this segment """ maximum_grade: Optional[float] = None """ The segments's maximum grade, in percents """ name: Optional[str] = None """ The name of this segment """ private: Optional[bool] = None """ Whether this segment is private. """ start_latlng: Optional[LatLng] = None state: Optional[str] = None """ The segments's state or geographical region. """ class TimedZoneRange(ZoneRange): """ A union type representing the time spent in a given zone. """ time: Optional[int] = None """ The number of seconds spent in this zone """ class Zones(BaseModel): heart_rate: Optional[HeartRateZoneRanges] = None power: Optional[PowerZoneRanges] = None class DetailedSegment(SummarySegment): athlete_count: Optional[int] = None """ The number of unique athletes who have an effort for this segment """ created_at: Optional[datetime] = None """ The time at which the segment was created. """ effort_count: Optional[int] = None """ The total number of efforts for this segment """ hazardous: Optional[bool] = None """ Whether this segment is considered hazardous """ map: Optional[PolylineMap] = None star_count: Optional[int] = None """ The number of stars for this segment """ total_elevation_gain: Optional[float] = None """ The segment's total elevation gain. """ updated_at: Optional[datetime] = None """ The time at which the segment was last updated. """ class DetailedSegmentEffort(SummarySegmentEffort): activity: Optional[MetaActivity] = None athlete: Optional[MetaAthlete] = None average_cadence: Optional[float] = None """ The effort's average cadence """ average_heartrate: Optional[float] = None """ The heart heart rate of the athlete during this effort """ average_watts: Optional[float] = None """ The average wattage of this effort """ device_watts: Optional[bool] = None """ For riding efforts, whether the wattage was reported by a dedicated recording device """ end_index: Optional[int] = None """ The end index of this effort in its activity's stream """ hidden: Optional[bool] = None """ Whether this effort should be hidden when viewed within an activity """ kom_rank: Optional[conint(ge=1, le=10)] = None """ The rank of the effort on the global leaderboard if it belongs in the top 10 at the time of upload """ max_heartrate: Optional[float] = None """ The maximum heart rate of the athlete during this effort """ moving_time: Optional[int] = None """ The effort's moving time """ name: Optional[str] = None """ The name of the segment on which this effort was performed """ pr_rank: Optional[conint(ge=1, le=3)] = None """ The rank of the effort on the athlete's leaderboard if it belongs in the top 3 at the time of upload """ segment: Optional[SummarySegment] = None start_index: Optional[int] = None """ The start index of this effort in its activity's stream """ class ExplorerResponse(BaseModel): segments: Optional[List[ExplorerSegment]] = None """ The set of segments matching an explorer request """ class Route(BaseModel): athlete: Optional[SummaryAthlete] = None created_at: Optional[datetime] = None """ The time at which the route was created """ description: Optional[str] = None """ The description of the route """ distance: Optional[float] = None """ The route's distance, in meters """ elevation_gain: Optional[float] = None """ The route's elevation gain. """ estimated_moving_time: Optional[int] = None """ Estimated time in seconds for the authenticated athlete to complete route """ id: Optional[int] = None """ The unique identifier of this route """ id_str: Optional[str] = None """ The unique identifier of the route in string format """ map: Optional[PolylineMap] = None name: Optional[str] = None """ The name of this route """ private: Optional[bool] = None """ Whether this route is private """ segments: Optional[List[SummarySegment]] = None """ The segments traversed by this route """ starred: Optional[bool] = None """ Whether this route is starred by the logged-in athlete """ sub_type: Optional[int] = None """ This route's sub-type (1 for road, 2 for mountain bike, 3 for cross, 4 for trail, 5 for mixed) """ timestamp: Optional[int] = None """ An epoch timestamp of when the route was created """ type: Optional[int] = None """ This route's type (1 for ride, 2 for runs) """ updated_at: Optional[datetime] = None """ The time at which the route was last updated """ class TimedZoneDistribution(BaseModel): """ Stores the exclusive ranges representing zones and the time spent in each. """ __root__: List[TimedZoneRange] """ Stores the exclusive ranges representing zones and the time spent in each. """ class ActivityZone(BaseModel): custom_zones: Optional[bool] = None distribution_buckets: Optional[TimedZoneDistribution] = None max: Optional[int] = None points: Optional[int] = None score: Optional[int] = None sensor_based: Optional[bool] = None type: Optional[Literal["heartrate", "power"]] = None class DetailedActivity(SummaryActivity): best_efforts: Optional[List[DetailedSegmentEffort]] = None calories: Optional[float] = None """ The number of kilocalories consumed during this activity """ description: Optional[str] = None """ The description of the activity """ device_name: Optional[str] = None """ The name of the device used to record the activity """ embed_token: Optional[str] = None """ The token used to embed a Strava activity """ gear: Optional[SummaryGear] = None laps: Optional[List[Lap]] = None photos: Optional[PhotosSummary] = None segment_efforts: Optional[List[DetailedSegmentEffort]] = None splits_metric: Optional[List[Split]] = None """ The splits of this activity in metric units (for runs) """ splits_standard: Optional[List[Split]] = None """ The splits of this activity in imperial units (for runs) """ stravalib-1.3.0/stravalib/tests/000077500000000000000000000000001442076457100166455ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/__init__.py000066400000000000000000000010441442076457100207550ustar00rootroot00000000000000import os.path import sys if sys.version_info < (2, 7): # TODO: We can probably add this smartly to setup.py try: from unittest2 import TestCase except ImportError: raise Exception("Need unittest2 for running tests under python 2.6") else: from unittest import TestCase TESTS_DIR = os.path.dirname(__file__) RESOURCES_DIR = os.path.join(TESTS_DIR, "resources") class TestBase(TestCase): def setUp(self): super(TestBase, self).setUp() def tearDown(self): super(TestBase, self).tearDown() stravalib-1.3.0/stravalib/tests/auth_responder.py000066400000000000000000000111621442076457100222420ustar00rootroot00000000000000#!/usr/bin/env python """ A basic authorization server. Run this with your Strava Client ID and Client Secret and access from your browser (because the Strava OAuth page uses javascript) in order to get a resulting access token. That access token can then be used to initialize a Client that can read (and/or write) data from the Strava API. You must run this from a virtualenv that has stravalib installed. Example Usage: (env) shell$ python -m stravalib.tests.auth_responder --port=8000 --client-id=123 --client-secret=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef Then connect in your browser to http://localhost:8000/ The redirected response (from Strava) will deliver a code that can be exchanged for a token. The access token will be presented in the browser after the exchange. Save this value into your config (e.g. into your test.ini) to run functional tests. """ import argparse import logging import threading from http.server import BaseHTTPRequestHandler, HTTPServer from urllib import parse as urlparse from stravalib import Client class StravaAuthHTTPServer(HTTPServer): def __init__( self, server_address, RequestHandlerClass, client_id, client_secret, bind_and_activate=True, ): HTTPServer.__init__( self, server_address, RequestHandlerClass, bind_and_activate=bind_and_activate, ) self.logger = logging.getLogger("auth_server.http") self.client_id = client_id self.client_secret = client_secret self.listening_event = threading.Event() def serve_forever(self, *args, **kwargs): self.listening_event.set() return HTTPServer.serve_forever(self, *args, **kwargs) class RequestHandler(BaseHTTPRequestHandler): def do_GET(self): request_path = self.path parsed_path = urlparse.urlparse(request_path) client = Client() if request_path.startswith("/authorization"): self.send_response(200) self.send_header(b"Content-type", b"text/plain") self.end_headers() self.wfile.write(b"Authorization Handler\n\n") code = urlparse.parse_qs(parsed_path.query).get("code") if code: code = code[0] token_response = client.exchange_code_for_token( client_id=self.server.client_id, client_secret=self.server.client_secret, code=code, ) access_token = token_response["access_token"] self.server.logger.info( "Exchanged code {} for access token {}".format( code, access_token ) ) self.wfile.write(b"Access Token: {}\n".format(access_token)) else: self.server.logger.error("No code param received.") self.wfile.write(b"ERROR: No code param recevied.\n") else: url = client.authorization_url( client_id=self.server.client_id, redirect_uri="http://localhost:{}/authorization".format( self.server.server_port ), ) self.send_response(302) self.send_header("Content-type", "text/plain") self.send_header(b"Location", bytes(url)) self.end_headers() self.wfile.write(b"Redirect to URL: {}\n".format(url)) def main(port, client_id, client_secret): logging.basicConfig( level=logging.INFO, format="%(levelname)-8s %(message)s" ) logger = logging.getLogger("auth_responder") logger.info("Listening on localhost:%s" % port) server = StravaAuthHTTPServer( ("", port), RequestHandler, client_id=client_id, client_secret=client_secret, ) server.serve_forever() if __name__ == "__main__": parser = argparse.ArgumentParser( description="Run a local web server to receive authorization responses from Strava." ) parser.add_argument( "-p", "--port", help="Which port to bind to", action="store", type=int, default=8000, ) parser.add_argument( "--client-id", help="Strava API Client ID", action="store", required=True, ) parser.add_argument( "--client-secret", help="Strava API Client Secret", action="store", required=True, ) args = parser.parse_args() main( port=args.port, client_id=args.client_id, client_secret=args.client_secret, ) stravalib-1.3.0/stravalib/tests/functional/000077500000000000000000000000001442076457100210075ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/functional/__init__.py000066400000000000000000000016471442076457100231300ustar00rootroot00000000000000import os from configparser import NoOptionError, SafeConfigParser from stravalib.client import Client from stravalib.tests import TESTS_DIR, TestBase TEST_CFG = os.path.join(TESTS_DIR, "test.ini") class FunctionalTestBase(TestBase): def setUp(self): super(FunctionalTestBase, self).setUp() if not os.path.exists(TEST_CFG): raise Exception( "Unable to run the write tests without a test.ini in that defines an access_token with write privs." ) cfg = SafeConfigParser() with open(TEST_CFG) as fp: cfg.readfp(fp, "test.ini") access_token = cfg.get("write_tests", "access_token") try: activity_id = cfg.get("activity_tests", "activity_id") except NoOptionError: activity_id = None self.client = Client(access_token=access_token) self.activity_id = activity_id stravalib-1.3.0/stravalib/tests/functional/test_client.py000066400000000000000000000310311442076457100236740ustar00rootroot00000000000000import datetime import requests from stravalib import model from stravalib import unithelper as uh from stravalib.tests.functional import FunctionalTestBase class ClientTest(FunctionalTestBase): def test_get_starred_segment(self): """ Test get_starred_segment """ i = 0 for segment in self.client.get_starred_segment(limit=5): self.assertIsInstance(segment, model.Segment) i += 1 self.assertGreater(i, 0) # star at least one segment self.assertLessEqual(i, 5) def test_get_activity(self): """Test basic activity fetching.""" activity = self.client.get_activity(96089609) self.assertEqual("El Dorado County, CA, USA", activity.location_city) self.assertIsInstance(activity.start_latlng, model.LatLon) self.assertAlmostEqual( -120.4357631, activity.start_latlng.lon, places=2 ) self.assertAlmostEqual( 38.74263759999999, activity.start_latlng.lat, places=2 ) self.assertIsInstance(activity.map, model.Map) self.assertIsInstance(activity.athlete, model.Athlete) self.assertEqual(1513, activity.athlete.id) # self.assertAlmostEqual(first, second, places, msg, delta) # Ensure that iw as read in with correct units self.assertEqual(22.5308, float(uh.kilometers(activity.distance))) def test_get_activity_and_segments(self): """Test include_all_efforts parameter on activity fetching.""" if not self.activity_id: self.fail( "Include an activity_id in test.ini to test segment_efforts" ) activity = self.client.get_activity( self.activity_id, include_all_efforts=True ) self.assertTrue(isinstance(activity.segment_efforts, list)) # Check also when we have no parameters segment_efforts is None activity_no_segments = self.client.get_activity(self.activity_id) self.assertTrue(activity.segment_efforts, None) def test_get_route(self): route = self.client.get_route(3445913) self.assertEqual("Baveno - Mottarone", route.name) self.assertAlmostEqual( 1265.20, float(uh.meters(route.elevation_gain)), 2 ) def test_get_activity_laps(self): activity = self.client.get_activity(165094211) laps = list(self.client.get_activity_laps(165094211)) self.assertEqual(5, len(laps)) # This obviously is far from comprehensive, just a sanity check self.assertEqual("Lap 1", laps[0].name) self.assertEqual(178.0, laps[0].max_heartrate) def test_get_activity_zones(self): """ Test loading zones for activity. """ zones = self.client.get_activity_zones(99895560) print(zones) self.assertEqual(1, len(zones)) self.assertIsInstance(zones[0], model.PaceActivityZone) # Indirectly activity = self.client.get_activity(99895560) self.assertEqual(len(zones), len(activity.zones)) self.assertEqual(zones[0].score, activity.zones[0].score) def test_activity_comments(self): """ Test loading comments for already-loaded activity. """ activity = self.client.get_activity(2290897) self.assertTrue(activity.comment_count > 0) comments = list(activity.comments) self.assertEqual(3, len(comments)) self.assertEqual( "I love Gordo's. I've been eating there for 20 years!", comments[0].text, ) def test_activity_photos(self): """ Test photos on activity """ activity = self.client.get_activity(643026323) self.assertTrue(activity.total_photo_count > 0) photos = list(activity.full_photos) self.assertEqual(len(photos), 1) self.assertEqual(len(photos), activity.total_photo_count) self.assertIsInstance(photos[0], model.ActivityPhoto) def test_activity_kudos(self): """ Test kudos on activity """ activity = self.client.get_activity(152668627) self.assertTrue(activity.kudos_count > 0) kudos = list(activity.kudos) self.assertGreater(len(kudos), 6) self.assertEqual(len(kudos), activity.kudos_count) self.assertIsInstance(kudos[0], model.ActivityKudos) def test_activity_streams(self): """ Test activity streams """ stypes = [ "time", "latlng", "distance", "altitude", "velocity_smooth", "heartrate", "cadence", "watts", "temp", "moving", "grade_smooth", ] streams = self.client.get_activity_streams(152668627, stypes, "low") self.assertGreater(len(streams.keys()), 3) for k in streams.keys(): self.assertIn(k, stypes) # time stream self.assertIsInstance(streams["time"].data[0], int) self.assertGreater(streams["time"].original_size, 100) self.assertEqual(streams["time"].resolution, "low") self.assertEqual(len(streams["time"].data), 100) # latlng stream self.assertIsInstance(streams["latlng"].data, list) self.assertIsInstance(streams["latlng"].data[0][0], float) def test_route_streams(self): """ Test toute streams """ stypes = ["latlng", "distance", "altitude"] streams = self.client.get_route_streams(3445913) self.assertEqual(len(streams.keys()), 3) for t in stypes: self.assertIn(t, streams.keys()) def test_related_activities(self): """ Test get_related_activities on an activity and related property of Activity """ activity_id = 152668627 activity = self.client.get_activity(activity_id) related_activities = list( self.client.get_related_activities(activity_id) ) # Check the number of related_activities matches what activity would expect self.assertEqual(len(related_activities), activity.athlete_count - 1) # Check the related property gives the same result related_activities_from_property = list(activity.related) self.assertEqual(related_activities, related_activities_from_property) def test_effort_streams(self): """ Test effort streams """ stypes = ["distance"] activity = self.client.get_activity(165479860) # 152668627) streams = self.client.get_effort_streams( activity.segment_efforts[0].id, stypes, "medium" ) self.assertIn("distance", streams.keys()) # distance stream self.assertIsInstance(streams["distance"].data[0], float) # xxx self.assertEqual(streams["distance"].resolution, "medium") self.assertEqual( len(streams["distance"].data), min(1000, streams["distance"].original_size), ) def test_get_curr_athlete(self): athlete = self.client.get_athlete() # Just some basic sanity checks here self.assertTrue(len(athlete.firstname) > 0) self.assertTrue(athlete.athlete_type in ["runner", "cyclist"]) def test_get_athlete_clubs(self): clubs = self.client.get_athlete_clubs() self.assertEqual(3, len(clubs)) self.assertEqual("Team Roaring Mouse", clubs[0].name) self.assertEqual("Team Strava Cycling", clubs[1].name) self.assertEqual("Team Strava Cyclocross", clubs[2].name) clubs_indirect = self.client.get_athlete().clubs self.assertEqual(3, len(clubs_indirect)) self.assertEqual(clubs[0].name, clubs_indirect[0].name) self.assertEqual(clubs[1].name, clubs_indirect[1].name) self.assertEqual(clubs[2].name, clubs_indirect[2].name) def test_get_gear(self): g = self.client.get_gear("g69911") self.assertTrue(float(g.distance) >= 3264.67) self.assertEqual("Salomon XT Wings 2", g.name) self.assertEqual("Salomon", g.brand_name) self.assertTrue(g.primary) self.assertEqual(model.DETAILED, g.resource_state) self.assertEqual("g69911", g.id) self.assertEqual("XT Wings 2", g.model_name) self.assertEqual("", g.description) def test_get_segment(self): segment = self.client.get_segment(229781) self.assertIsInstance(segment, model.Segment) print(segment) self.assertEqual("Hawk Hill", segment.name) self.assertAlmostEqual( 2.68, float(uh.kilometers(segment.distance)), places=2 ) # Fetch leaderboard lb = segment.leaderboard self.assertEqual(10, len(lb)) # 10 top results, 5 bottom results def test_get_segment_efforts(self): # test with string efforts = self.client.get_segment_efforts( 4357415, start_date_local="2012-12-23T00:00:00Z", end_date_local="2012-12-23T11:00:00Z", ) print(efforts) i = 0 for effort in efforts: print(effort) self.assertEqual(4357415, effort.segment.id) self.assertIsInstance(effort, model.BaseEffort) effort_date = effort.start_date_local self.assertEqual(effort_date.strftime("%Y-%m-%d"), "2012-12-23") i += 1 print(i) self.assertGreater(i, 2) # also test with datetime object start_date = datetime.datetime(2012, 12, 31, 6, 0) end_date = start_date + datetime.timedelta(hours=12) efforts = self.client.get_segment_efforts( 4357415, start_date_local=start_date, end_date_local=end_date, ) print(efforts) i = 0 for effort in efforts: print(effort) self.assertEqual(4357415, effort.segment.id) self.assertIsInstance(effort, model.BaseEffort) effort_date = effort.start_date_local self.assertEqual(effort_date.strftime("%Y-%m-%d"), "2012-12-31") i += 1 print(i) self.assertGreater(i, 2) def test_segment_explorer(self): bounds = (37.821362, -122.505373, 37.842038, -122.465977) results = self.client.explore_segments(bounds) # This might be brittle self.assertEqual("Hawk Hill", results[0].name) # Fetch full segment segment = results[0].segment self.assertEqual(results[0].name, segment.name) # For some reason these don't follow the simple math rules one might expect (so we round to int) self.assertAlmostEqual( results[0].elev_difference, segment.elevation_high - segment.elevation_low, places=0, ) class AthleteStatsTest(FunctionalTestBase): """ Tests the functionality for collecting athlete statistics https://developers.strava.com/docs/reference/#api-Athletes-getStats """ def test_basic_get_from_client(self): stats = self.client.get_athlete_stats() self.assertIsInstance(stats, model.AthleteStats) self.assertIsInstance(stats.recent_ride_totals, model.ActivityTotals) print("Biggest climb: {!r}".format(stats.biggest_climb_elevation_gain)) # Check biggest_climb_elevation_gain has been set self.assertTrue( uh.meters(stats.biggest_climb_elevation_gain) >= uh.meters(0) ) def test_get_from_client_with_authenticated_id(self): athlete_id = self.client.get_athlete().id stats = self.client.get_athlete_stats(athlete_id) self.assertIsInstance(stats, model.AthleteStats) # Check same as before self.assertEqual( stats.biggest_climb_elevation_gain, self.client.get_athlete_stats().biggest_climb_elevation_gain, ) def test_get_from_client_with_wrong_id(self): CAV_ID = 1353775 # Currently raises a requests.exceptions.HTTPError, TODO: better error handling self.assertRaises( requests.exceptions.HTTPError, self.client.get_athlete_stats, CAV_ID, ) def test_athlete_stats_property_option(self): a = self.client.get_athlete() stats = a.stats self.assertIsInstance(stats, model.AthleteStats) def test_athlete_stats_cached(self): a = self.client.get_athlete() a._stats = "Not None" stats = a.stats self.assertEqual(stats, "Not None") def test_athlete_property_not_authenticated(self): with self.assertRaises(NotImplementedError): cav = self.client.get_athlete(1353775) stravalib-1.3.0/stravalib/tests/functional/test_client_rate_limiter.py000066400000000000000000000054251442076457100264440ustar00rootroot00000000000000import time from stravalib import exc from stravalib.tests.functional import FunctionalTestBase from stravalib.util.limiter import DefaultRateLimiter, XRateLimitRule class ClientDefaultRateLimiterTest(FunctionalTestBase): def test_fail_on_rate_limit_exceeded(self): """Use this test as an example""" # setup 'short' limit for testing self.client.protocol.rate_limiter.rules = [] self.client.protocol.rate_limiter.rules.append( XRateLimitRule( { "short": { "usage": 0, "limit": 600, "time": 5, "lastExceeded": None, }, "long": { "usage": 0, "limit": 30000, "time": 5, "lastExceeded": None, }, } ) ) # interact with api to get the limits self.client.get_athlete() # acces the default rate limit rule rate_limit_rule = self.client.protocol.rate_limiter.rules[0] # get any of the rate limits, ex the 'short' limit = rate_limit_rule.rate_limits["short"] # get current usage usage = limit["usage"] print("last rate limit usage is {0}".format(usage)) # for testing purpses set the limit to usage limit["limit"] = usage print("changing limit to {0}".format(limit["limit"])) # expect exception because of RateLimit has been # exceeded (or reached max) with self.assertRaises(exc.RateLimitExceeded): self.client.get_athlete() # request fired to early (less than 5 sec) causes timeout exception with self.assertRaises(exc.RateLimitTimeout): self.client.get_athlete() # once rate limit has exceeded wait until another reuqest is possible # check if timout has been set self.assertTrue(rate_limit_rule.limit_timeout > 0) print("limit timeout {0}".format(rate_limit_rule.limit_timeout)) # reseting limit # simulates Strava api - it would set the usage again to 0 limit["limit"] = 600 print("resetting limit to {0}".format(limit["limit"])) try: # waiting until timeout expires time.sleep(5) # this time it should work again self.client.get_athlete() self.assertTrue("No exception raised") except exc.RateLimitExceeded as e: self.fail("limiter raised RateLimitTimeout unexpectedly!") # continuse other tests with DefaultRateLimiter print("setting default rate limiter") self.client.protocol.rate_limiter = DefaultRateLimiter() stravalib-1.3.0/stravalib/tests/functional/test_client_write.py000066400000000000000000000057301442076457100251150ustar00rootroot00000000000000import os from datetime import datetime, timedelta from stravalib import exc, model from stravalib import unithelper as uh from stravalib.tests import RESOURCES_DIR from stravalib.tests.functional import FunctionalTestBase class ClientWriteTest(FunctionalTestBase): def test_create_activity(self): """ Test Client.create_activity simple case. """ now = datetime.now().replace(microsecond=0) a = self.client.create_activity( "test_create_activity#simple", activity_type="Ride", start_date_local=now, elapsed_time=timedelta(hours=3, minutes=4, seconds=5), distance=uh.miles(15.2), ) print(a) self.assertIsInstance(a, model.Activity) self.assertEqual("test_create_activity#simple", a.name) self.assertEqual(now, a.start_date_local) self.assertEqual( round(float(uh.miles(15.2)), 2), round(float(uh.miles(a.distance)), 2), ) self.assertEqual( timedelta(hours=3, minutes=4, seconds=5), a.elapsed_time ) def test_update_activity(self): """ Test Client.update_activity simple case. """ now = datetime.now().replace(microsecond=0) a = self.client.create_activity( "test_update_activity#create", activity_type="Ride", start_date_local=now, elapsed_time=timedelta(hours=3, minutes=4, seconds=5), distance=uh.miles(15.2), ) self.assertIsInstance(a, model.Activity) self.assertEqual("test_update_activity#create", a.name) update1 = self.client.update_activity( a.id, name="test_update_activivty#update" ) self.assertEqual("test_update_activivty#update", update1.name) self.assertFalse(update1.private) self.assertFalse(update1.trainer) self.assertFalse(update1.commute) update2 = self.client.update_activity(a.id, private=True) self.assertTrue(update2.private) update3 = self.client.update_activity(a.id, trainer=True) self.assertTrue(update3.private) self.assertTrue(update3.trainer) def test_upload_activity(self): """ Test uploading an activity. NOTE: This requires clearing out the uploaded activities from configured writable Strava acct. """ with open(os.path.join(RESOURCES_DIR, "sample.tcx")) as fp: uploader = self.client.upload_activity(fp, data_type="tcx") self.assertTrue(uploader.is_processing) a = uploader.wait() self.assertTrue(uploader.is_complete) self.assertIsInstance(a, model.Activity) self.assertEqual("02/21/2009 Leiden, ZH, The Netherlands", a.name) # And we'll get an error if we try the same file again with self.assertRaises(exc.ActivityUploadFailed): self.client.upload_activity(fp, data_type="tcx") stravalib-1.3.0/stravalib/tests/functional/test_result_iterator.py000066400000000000000000000043731442076457100256560ustar00rootroot00000000000000import functools from stravalib import model from stravalib.client import BatchedResultsIterator from stravalib.tests.functional import FunctionalTestBase class ResultIteratorTest(FunctionalTestBase): def setUp(self): super(ResultIteratorTest, self).setUp() self.protocol = self.client.protocol def test_limit_call(self): """Test setting the limit in method call.""" result_fetcher = functools.partial( self.protocol.get, "/athlete/activities" ) results = BatchedResultsIterator( entity=model.Activity, result_fetcher=result_fetcher, limit=10, per_page=2, ) results = list(results) self.assertEqual(10, len(results)) def test_multiple_iterator_calls(self): """Test multiple calls of the iterator.""" result_fetcher = functools.partial( self.protocol.get, "/athlete/activities" ) results = BatchedResultsIterator( entity=model.Activity, result_fetcher=result_fetcher, limit=10, per_page=2, ) results.limit = 10 results1 = list(results) results2 = list(results) self.assertEqual(10, len(results1)) self.assertEqual(len(results1), len(results2)) def test_limit_iterator(self): """Test setting the limit on the iterator.""" result_fetcher = functools.partial( self.protocol.get, "/athlete/activities" ) results = BatchedResultsIterator( entity=model.Activity, result_fetcher=result_fetcher, limit=10, per_page=2, ) results.limit = 10 results = list(results) self.assertEqual(10, len(results)) # TODO: use a mock here to figure out how many calls are happening under the hood. def test_empty(self): """Test iterating over empty results.""" # Specify two thing that we happen to know will return 0 results def pretend_fetcher(page, per_page): return [] ri = BatchedResultsIterator( entity=model.Shoe, result_fetcher=pretend_fetcher ) results = list(ri) self.assertEqual(0, len(results)) stravalib-1.3.0/stravalib/tests/integration/000077500000000000000000000000001442076457100211705ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/integration/__init__.py000066400000000000000000000000001442076457100232670ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/integration/conftest.py000066400000000000000000000005231442076457100233670ustar00rootroot00000000000000import pytest from stravalib import Client from stravalib.tests.integration.strava_api_stub import StravaAPIMock @pytest.fixture def mock_strava_api(): with StravaAPIMock() as api_mock: api_mock.add_passthru("https://developers.strava.com/swagger") yield api_mock @pytest.fixture def client(): return Client() stravalib-1.3.0/stravalib/tests/integration/strava_api_stub.py000066400000000000000000000155231442076457100247360ustar00rootroot00000000000000import json import logging import os import re from functools import lru_cache, wraps from typing import Any, Callable, Dict, Optional import requests from responses import BaseResponse, RequestsMock from stravalib.protocol import ApiV3 from stravalib.tests import RESOURCES_DIR LOGGER = logging.getLogger(__name__) @lru_cache(maxsize=1) def _get_strava_api_paths(): use_local = False try: strava_api_swagger_response = requests.get( "https://developers.strava.com/swagger/swagger.json" ) if strava_api_swagger_response.status_code != 200: use_local = True except requests.exceptions.ConnectionError: use_local = True if use_local: LOGGER.warning( "Failed to retrieve recent swagger API definition from Strava, using " "(potentially stale) version from local resources" ) with open( os.path.join(RESOURCES_DIR, "strava_swagger.json"), "r" ) as swagger_file: return json.load(swagger_file)["paths"] else: return strava_api_swagger_response.json()["paths"] def _api_method_adapter(api_method: Callable) -> Callable: """ Decorator for mock registration methods of `responses.RequestsMock` """ @wraps(api_method) def method_wrapper( *args, response_update: Dict[str, Any] = None, n_results: Optional[int] = None, **kwargs, ) -> BaseResponse: """ Wrapper for mock registration methods of `responses.RequestsMock` Parameters ---------- response_update: Dict that will be used to update any JSON object defined as an example response in the API's swagger.json n_results: The number of example objects to be returned by the mock response (in case of an array response). *args: Url and/or other positional arguments that would otherwise be provided to the `responses` registration methods. Note that url is expected to be relative, matching one of the paths defined in the Strava API's swagger.json. **kwargs Keyword arguments that would otherwise be provided to the `responses` registration methods. Returns ------- A mocked response registration object This wrapper expects a relative url that matches one of the paths in the swagger.json of Strava's API, e.g., `/activities/{Id}`. For the given HTTP method and status code, the corresponding example JSON response is retrieved from the swagger.json, and used in the mock registration as the response body. This response can be updated with the optional `response_update` argument (e.g. to set specific fields). If the response is a JSON array, the `n_results` argument specifies how many objects are in the response. Alternatively, a complete response can be specified using the `json` or `body` keywords, as is usually done using the `responses` library. In that case, the response updating - and multiplication mechanisms above are ignored and the given response is returned as-is. """ # get url from args/kwargs try: relative_url = args[0] except IndexError: try: relative_url = kwargs.pop("url") except KeyError: raise ValueError( "Expecting url either as first positional argument or keyword argument" ) # match url with swagger path path_info = _get_strava_api_paths()[relative_url] # find default response in swagger if no json response is provided in the kwargs if "json" not in kwargs: http_method = api_method.args[0].lower() response_status = kwargs.get("status", 200) try: method_responses = path_info[http_method] except KeyError: raise ValueError( f"Endpoint {relative_url} has no support for method {http_method}" ) try: response = method_responses["responses"][str(response_status)][ "examples" ]["application/json"] if isinstance(response, list) and n_results is not None: # Make sure response has n_results items response = (response * (n_results // len(response) + 1))[ :n_results ] elif n_results is not None: # Force single response in example into result list (not all examples provide lists) LOGGER.warning( f"Forcing example single response into list" ) response = [{**response, **response_update}] * n_results except KeyError: LOGGER.warning( f"There are no known example responses for HTTP status {response_status}, " f"using empty response. You may want to provide a full json response " f'using the "json" keyword argument.' ) response = {} # update fields if necessary if response_update is not None: if isinstance(response, list): response = [ {**item, **response_update} for item in response ] else: response.update(response_update) kwargs.update({"json": response}) # replace named parameters in url by wildcards matching_url = re.sub(r"\{\w+\}", r"\\w+", relative_url) return api_method( re.compile( ApiV3().resolve_url(matching_url) ), # replaces url from args[0] *args[1:], **kwargs, ) return method_wrapper class StravaAPIMock(RequestsMock): """ A stub/mock for the Strava API. This is a thin wrapper around `responses.RequestsMock`. It intercepts calls for registering mock responses and replaces these by a decorated method that supports convenience arguments that are not in the `responses` package. The methods `delete`, `get`, `head`, `options`, `patch`, `post`, and `put` are intercepted (and decorated), while the generic method `add` can be used to bypass the decoration and directly use the `responses` API. """ def __getattribute__(self, name): attr = super().__getattribute__(name) if name in [ "delete", "get", "head", "options", "patch", "post", "put", ]: return _api_method_adapter(attr) else: return attr stravalib-1.3.0/stravalib/tests/integration/test_client.py000066400000000000000000000600201442076457100240550ustar00rootroot00000000000000import datetime import json import os from unittest import mock import pytest import responses from responses import matchers import stravalib.unithelper as uh from stravalib.client import ActivityUploader from stravalib.exc import AccessUnauthorized, ActivityPhotoUploadFailed from stravalib.model import Athlete from stravalib.tests import RESOURCES_DIR from stravalib.unithelper import UnitConverter, meters, miles def test_get_athlete(mock_strava_api, client): mock_strava_api.get("/athlete", response_update={"id": 42}) athlete = client.get_athlete() assert athlete.id == 42 assert athlete.measurement_preference == "feet" @pytest.mark.parametrize( "include_all_efforts,expected_url", ( (None, "/activities/42?include_all_efforts=False"), (False, "/activities/42?include_all_efforts=False"), (True, "/activities/42?include_all_efforts=True"), ), ) def test_get_activity( mock_strava_api, client, include_all_efforts, expected_url ): test_activity_id = 42 mock_strava_api.get( "/activities/{id}", response_update={"id": test_activity_id} ) if include_all_efforts is not None: activity = client.get_activity(test_activity_id, include_all_efforts) else: activity = client.get_activity(test_activity_id) assert mock_strava_api.calls[-1].request.url.endswith(expected_url) assert activity.id == test_activity_id def test_get_activity_laps(mock_strava_api, client): mock_strava_api.get( "/activities/{id}/laps", response_update={"distance": 1000}, n_results=2, ) laps = list(client.get_activity_laps(42)) assert len(laps) == 2 assert laps[0].distance == meters(1000) def test_get_club_activities(mock_strava_api, client): mock_strava_api.get( "/clubs/{id}/activities", response_update={"distance": 1000}, n_results=2, ) activities = list(client.get_club_activities(42)) assert len(activities) == 2 assert activities[0].distance == meters(1000) def test_get_activity_zones(mock_strava_api, client): # Unfortunately, there is no example response for this endpoint in swagger.json with open( os.path.join(RESOURCES_DIR, "example_zone_response.json"), "r" ) as zone_response_fp: zone_response = json.load(zone_response_fp) mock_strava_api.get("/activities/{id}/zones", json=zone_response) activity_zones = client.get_activity_zones(42) assert len(activity_zones) == 2 assert activity_zones[0].type == "heartrate" assert activity_zones[0].sensor_based def test_get_activity_streams(mock_strava_api, client): # TODO: parameterize test to cover all branching mock_strava_api.get( "/activities/{id}/streams", response_update={"data": [1, 2, 3]} ) # the example in swagger.json returns a distance stream streams = client.get_activity_streams(42) assert streams["distance"].data == [1, 2, 3] @pytest.mark.parametrize( "update_kwargs,expected_params,expected_warning,expected_exception", ( ({}, {}, None, None), ({"name": "foo"}, {"name": "foo"}, None, None), ({"activity_type": "foo"}, {}, None, ValueError), ({"activity_type": "Run"}, {"type": "run"}, DeprecationWarning, None), ({"activity_type": "run"}, {"type": "run"}, DeprecationWarning, None), ({"activity_type": "RUN"}, {"type": "run"}, DeprecationWarning, None), ({"sport_type": "foo"}, {}, None, ValueError), ({"sport_type": "TrailRun"}, {"sport_type": "TrailRun"}, None, None), ( {"activity_type": "Run", "sport_type": "TrailRun"}, {"sport_type": "TrailRun"}, DeprecationWarning, None, ), ({"private": True}, {"private": "1"}, DeprecationWarning, None), ({"commute": True}, {"commute": "1"}, None, None), ({"trainer": True}, {"trainer": "1"}, None, None), ({"gear_id": "fb42"}, {"gear_id": "fb42"}, None, None), ({"description": "foo"}, {"description": "foo"}, None, None), ( {"device_name": "foo"}, {"device_name": "foo"}, DeprecationWarning, None, ), ({"hide_from_home": False}, {"hide_from_home": "0"}, None, None), ), ) def test_update_activity( mock_strava_api, client, update_kwargs, expected_params, expected_warning, expected_exception, ): activity_id = 42 def _call_update_activity(): _ = client.update_activity(activity_id, **update_kwargs) assert mock_strava_api.calls[-1].request.params == expected_params if expected_exception: with pytest.raises(expected_exception): _call_update_activity() else: mock_strava_api.put("/activities/{id}", status=200) if expected_warning: with pytest.warns(expected_warning): _call_update_activity() else: _call_update_activity() @pytest.mark.parametrize( "activity_file_type,data_type,upload_kwargs,expected_params,expected_warning,expected_exception", ( ("file", "tcx", {}, {"data_type": "tcx"}, None, None), ("str", "tcx", {}, {"data_type": "tcx"}, None, None), ("bytes", "tcx", {}, {"data_type": "tcx"}, None, None), ("not_supported", "tcx", {}, {}, None, TypeError), ("file", "invalid", {}, {}, None, ValueError), ( "file", "tcx", {"name": "name"}, {"data_type": "tcx", "name": "name"}, None, None, ), ( "file", "tcx", {"description": "descr"}, {"data_type": "tcx", "description": "descr"}, None, None, ), ( "file", "tcx", {"activity_type": "run"}, {"data_type": "tcx", "activity_type": "run"}, FutureWarning, None, ), ( "file", "tcx", {"activity_type": "Run"}, {"data_type": "tcx", "activity_type": "run"}, FutureWarning, None, ), ("file", "tcx", {"activity_type": "sleep"}, None, None, ValueError), ( "file", "tcx", {"private": True}, {"data_type": "tcx", "private": "1"}, DeprecationWarning, None, ), ( "file", "tcx", {"external_id": 42}, {"data_type": "tcx", "external_id": "42"}, None, None, ), ( "file", "tcx", {"trainer": True}, {"data_type": "tcx", "trainer": "1"}, None, None, ), ( "file", "tcx", {"commute": False}, {"data_type": "tcx", "commute": "0"}, None, None, ), ), ) def test_upload_activity( mock_strava_api, client, activity_file_type, data_type, upload_kwargs, expected_params, expected_warning, expected_exception, ): init_upload_response = { "id": 1, "id_str": "abc", "external_id": "abc", "status": "default_status", "error": "", } def _call_and_assert(file): _ = client.upload_activity(file, data_type, **upload_kwargs) assert mock_strava_api.calls[-1].request.params == expected_params def _call_upload(file): if expected_exception: with pytest.raises(expected_exception): _call_and_assert(file) else: mock_strava_api.post( "/uploads", status=201, json=init_upload_response ) if expected_warning: with pytest.warns(expected_warning): _call_and_assert(file) else: _call_and_assert(file) with open(os.path.join(RESOURCES_DIR, "sample.tcx")) as f: if activity_file_type == "file": _call_upload(f) elif activity_file_type == "str": _call_upload(f.read()) elif activity_file_type == "bytes": _call_upload(f.read().encode("utf-8")) else: _call_upload({}) @pytest.mark.parametrize( "update_kwargs,expected_params,expected_warning,expected_exception", ( ({}, {}, None, None), ({"city": "foo"}, {"city": "foo"}, DeprecationWarning, None), ({"state": "foo"}, {"state": "foo"}, DeprecationWarning, None), ({"country": "foo"}, {"country": "foo"}, DeprecationWarning, None), ({"sex": "foo"}, {"sex": "foo"}, DeprecationWarning, None), ({"weight": "foo"}, {}, None, ValueError), ({"weight": "99.9"}, {"weight": "99.9"}, None, None), ({"weight": 99.9}, {"weight": "99.9"}, None, None), ({"weight": 99}, {"weight": "99.0"}, None, None), ), ) def test_update_athlete( mock_strava_api, client, update_kwargs, expected_params, expected_warning, expected_exception, ): def _call_and_assert(): _ = client.update_athlete(**update_kwargs) assert mock_strava_api.calls[-1].request.params == expected_params if expected_exception: with pytest.raises(expected_exception): _call_and_assert() else: mock_strava_api.put("/athlete", status=200) if expected_warning: with pytest.warns(expected_warning): _call_and_assert() else: _call_and_assert() @pytest.mark.parametrize( "extra_create_kwargs,extra_expected_params,expected_exception", ( ({}, {}, None), ({"activity_type": "run"}, {"type": "run"}, None), ({"activity_type": "Run"}, {"type": "run"}, None), ({"activity_type": "sleep"}, {}, ValueError), ( {"start_date_local": datetime.datetime(2022, 1, 1, 10, 0, 0)}, {"start_date_local": "2022-01-01T10:00:00Z"}, None, ), ( {"elapsed_time": datetime.timedelta(minutes=1)}, {"elapsed_time": "60"}, None, ), ({"distance": 1000}, {"distance": "1000"}, None), ({"distance": miles(1)}, {"distance": "1609.344"}, None), ({"description": "foo"}, {"description": "foo"}, None), ), ) def test_create_activity( mock_strava_api, client, extra_create_kwargs, extra_expected_params, expected_exception, ): default_call_kwargs = { "name": "test", "activity_type": "Run", "start_date_local": "2022-01-01T09:00:00", "elapsed_time": 3600, } default_request_params = { "name": "test", "type": "run", "start_date_local": "2022-01-01T09:00:00", "elapsed_time": "3600", } call_kwargs = {**default_call_kwargs, **extra_create_kwargs} expected_params = {**default_request_params, **extra_expected_params} def _call_and_assert(): _ = client.create_activity(**call_kwargs) assert mock_strava_api.calls[-1].request.params == expected_params if expected_exception: with pytest.raises(expected_exception): _call_and_assert() else: mock_strava_api.post("/activities", status=201) _call_and_assert() def test_activity_uploader(mock_strava_api, client): test_activity_id = 42 init_upload_response = { "id": 1, "id_str": "abc", "external_id": "abc", "status": "default_status", "error": "", } mock_strava_api.post("/uploads", status=201, json=init_upload_response) mock_strava_api.get("/uploads/{uploadId}", json=init_upload_response) mock_strava_api.get("/uploads/{uploadId}", json=init_upload_response) mock_strava_api.get( "/uploads/{uploadId}", json={**init_upload_response, "activity_id": test_activity_id}, ) mock_strava_api.get( "/activities/{id}", response_update={"id": test_activity_id} ) with open(os.path.join(RESOURCES_DIR, "sample.tcx")) as activity_file: uploader = client.upload_activity(activity_file, data_type="tcx") assert uploader.is_processing activity = uploader.wait() assert uploader.is_complete assert activity.id == test_activity_id def test_get_route(mock_strava_api, client): with open( os.path.join(RESOURCES_DIR, "example_route_response.json"), "r" ) as route_response_fp: route_response = json.load(route_response_fp) mock_strava_api.get("/routes/{id}", status=200, json=route_response) route = client.get_route(42) assert route.name == "15k, no traffic" @responses.activate def test_create_subscription(mock_strava_api, client): responses.post( "https://www.strava.com/api/v3/push_subscriptions", json={ "application_id": 42, "object_type": "activity", "aspect_type": "create", "callback_url": "https://foobar.com", "created_at": 1674660406, }, status=200, ) created_subscription = client.create_subscription( 42, 42, "https://foobar.com" ) assert created_subscription.application_id == 42 @pytest.mark.parametrize( "raw,expected_verify_token,expected_response,expected_exception", ( ( {"hub.verify_token": "a", "hub.challenge": "b"}, "a", {"hub.challenge": "b"}, None, ), ( {"hub.verify_token": "foo", "hub.challenge": "b"}, "a", None, AssertionError, ), ), ) def test_handle_subscription_callback( client, raw, expected_verify_token, expected_response, expected_exception ): if expected_exception: with pytest.raises(expected_exception): client.handle_subscription_callback(raw, expected_verify_token) else: assert ( client.handle_subscription_callback(raw, expected_verify_token) == expected_response ) @pytest.mark.parametrize( "limit,n_raw_results,expected_n_segments", ( (None, 0, 0), (None, 10, 10), (10, 10, 10), (10, 20, 10), (10, 1, 1), (10, 0, 0), ), ) def test_get_starred_segments( mock_strava_api, client, limit, n_raw_results, expected_n_segments ): mock_strava_api.get( "/segments/starred", response_update={"name": "test_segment"}, n_results=n_raw_results, ) kwargs = {"limit": limit} if limit is not None else {} activity_list = list(client.get_starred_segments(**kwargs)) assert len(activity_list) == expected_n_segments if expected_n_segments > 0: assert activity_list[0].name == "test_segment" def test_get_club(mock_strava_api, client): mock_strava_api.get("/clubs/{id}", response_update={"name": "foo"}) club = client.get_club(42) assert club.name == "foo" @pytest.mark.parametrize("n_clubs", (0, 2)) def test_get_athlete_clubs(mock_strava_api, client, n_clubs): mock_strava_api.get( "/athlete/clubs", response_update={"name": "foo"}, n_results=n_clubs ) clubs = client.get_athlete_clubs() assert len(clubs) == n_clubs if clubs: assert clubs[0].name == "foo" @pytest.mark.parametrize("n_members", (0, 2)) def test_get_club_members(mock_strava_api, client, n_members): mock_strava_api.get( "/clubs/{id}/members", response_update={"lastname": "Doe"}, n_results=n_members, ) members = list(client.get_club_members(42)) assert len(members) == n_members if members: assert members[0].lastname == "Doe" @pytest.mark.parametrize( "athlete_id,authenticated_athlete,expected_biggest_ride_distance,expected_exception", ( (42, True, 1000, None), (42, False, None, AccessUnauthorized), (None, True, 1000, None), ), ) def test_get_athlete_stats( mock_strava_api, client, athlete_id, authenticated_athlete, expected_biggest_ride_distance, expected_exception, ): if athlete_id is None: mock_strava_api.get("/athlete", response_update={"id": 42}) if authenticated_athlete: mock_strava_api.get( "/athletes/{id}/stats", response_update={ "biggest_ride_distance": expected_biggest_ride_distance }, ) else: mock_strava_api.get( "/athletes/{id}/stats", json=[ { "resource": "Athlete", "field": "access_token", "code": "invalid", } ], status=401, ) if expected_exception: with pytest.raises(expected_exception): client.get_athlete_stats(athlete_id) else: stats = client.get_athlete_stats(athlete_id) assert stats.biggest_ride_distance == UnitConverter("meters")( expected_biggest_ride_distance ) def test_get_gear(mock_strava_api, client): mock_strava_api.get("/gear/{id}", response_update={"name": "foo_bike"}) assert client.get_gear(42).name == "foo_bike" @pytest.mark.parametrize( "limit,n_raw_results,expected_n_activities", ( (None, 10, 10), (None, 0, 0), (10, 10, 10), (10, 20, 10), (10, 1, 1), (10, 0, 0), ), ) def test_get_activities( mock_strava_api, client, limit, n_raw_results, expected_n_activities ): mock_strava_api.get( "/athlete/activities", response_update={"name": "test_activity"}, n_results=n_raw_results, ) kwargs = {"limit": limit} if limit is not None else {} activity_list = list(client.get_activities(**kwargs)) assert len(activity_list) == expected_n_activities if expected_n_activities > 0: assert activity_list[0].name == "test_activity" def test_get_activities_quantity_addition(mock_strava_api, client): mock_strava_api.get( "/athlete/activities", response_update={"distance": 1000.0}, n_results=2, ) act_list = list(client.get_activities(limit=2)) total_d = uh.meters(0) total_d += act_list[0].distance total_d += act_list[1].distance assert total_d == uh.meters(2000.0) def test_get_segment(mock_strava_api, client): mock_strava_api.get("/segments/{id}", response_update={"name": "foo"}) segment = client.get_segment(42) assert segment.name == "foo" def test_get_segment_effort(mock_strava_api, client): mock_strava_api.get( "/segment_efforts/{id}", response_update={"max_heartrate": 170} ) effort = client.get_segment_effort(42) assert effort.max_heartrate == 170 def test_get_activities_paged(mock_strava_api, client): for i in range(1, 4): params = {"page": i, "per_page": 200} mock_strava_api.get( "/athlete/activities", response_update={"id": i}, n_results=(200 if i < 3 else 100), match=[matchers.query_param_matcher(params)], ) activity_list = list(client.get_activities()) assert len(activity_list) == 500 assert activity_list[0].id == 1 assert activity_list[400].id == 3 @responses.activate def test_upload_activity_photo_works(client): """ Test uploading an activity with a photo. """ strava_pre_signed_uri = "https://strava-photo-uploads-prod.s3-accelerate.amazonaws.com/12345.jpg" photo_bytes = b"photo_data" photo_metadata_header = { "Content-Type": "image/jpeg", "Expect": "100-continue", "Host": "strava-photo-uploads-prod.s3-accelerate.amazonaws.com", } activity_upload_response = { "id": 12345, "external_id": "external_id", "error": None, "status": "Your activity is ready.", "activity_id": 12345, "photo_metadata": [ { "uri": strava_pre_signed_uri, "header": photo_metadata_header, "method": "PUT", "max_size": 1600, } ], } with responses.RequestsMock( assert_all_requests_are_fired=True ) as _responses: _responses.add( responses.PUT, "https://strava-photo-uploads-prod.s3-accelerate.amazonaws.com/12345.jpg", status=200, ) _responses.add( responses.GET, "https://www.strava.com/api/v3/uploads/12345", status=200, json=activity_upload_response, ) activity_uploader = ActivityUploader( client, response=activity_upload_response ) activity_uploader.upload_photo(photo=photo_bytes) def test_upload_activity_photo_fail_type_error(client): activity_uploader = ActivityUploader(client, response={}) with pytest.raises(ActivityPhotoUploadFailed) as error: activity_uploader.upload_photo(photo="photo_str") assert str(error.value) == "Photo must be bytes type" @mock.patch("stravalib.client.ActivityUploader.poll") def test_upload_activity_photo_fail_activity_upload_not_complete(client): activity_upload_response = { "id": 1234578, "external_id": "external_id", "error": None, "status": "Your activity is being processed.", } activity_uploader = ActivityUploader( client, response=activity_upload_response ) with pytest.raises(ActivityPhotoUploadFailed) as error: activity_uploader.upload_photo(photo=b"photo_bytes") assert str(error.value) == "Activity upload not complete" @pytest.mark.parametrize( "photo_metadata", ( None, [], [{}], ), ) @mock.patch("stravalib.client.ActivityUploader.poll") def test_upload_activity_photo_fail_not_supported(client, photo_metadata): activity_upload_response = { "id": 1234578, "external_id": "external_id", "error": None, "status": "Your activity is ready.", "activity_id": 1234578, "photo_metadata": photo_metadata, } activity_uploader = ActivityUploader( client, response=activity_upload_response ) with pytest.raises(ActivityPhotoUploadFailed) as error: activity_uploader.upload_photo(photo=b"photo_bytes") assert str(error.value) == "Photo upload not supported" def test_get_activity_comments(mock_strava_api, client): mock_strava_api.get( "/activities/{id}/comments", response_update={"text": "foo"}, n_results=2, ) comment_list = list( client.get_activity_comments(42) ) # no idea what the markdown param is supposed to do assert len(comment_list) == 2 assert comment_list[0].text == "foo" def test_explore_segments(mock_strava_api, client): # TODO parameterize test with multiple inputs # It is hard to patch the response for this one, since the # endpoint returns a nested list of segments. mock_strava_api.get("/segments/explore") segment_list = client.explore_segments((1, 2, 3, 4)) assert len(segment_list) == 1 assert segment_list[0].name == "Hawk Hill" def test_get_activity_kudos(mock_strava_api, client): mock_strava_api.get( "/activities/{id}/kudos", response_update={"lastname": "Doe"}, n_results=2, ) kudoer_list = list(client.get_activity_kudos(42)) assert len(kudoer_list) == 2 assert kudoer_list[0].lastname == "Doe" class TestIsAuthenticatedAthlete: def test_default(self, mock_strava_api, client): mock_strava_api.get("/athlete", response_update={"id": 42}) athlete = client.get_athlete() assert athlete.is_authenticated_athlete() def test_caching(self): athlete = Athlete(is_authenticated=True) assert athlete.is_authenticated_athlete() @pytest.mark.parametrize( "match_id,expected_result", ((False, False), (True, True)) ) def test_from_summary( self, mock_strava_api, client, match_id, expected_result ): mock_strava_api.get( "/clubs/{id}/members", response_update={"id": 42 if match_id else 21}, ) mock_strava_api.get("/athlete", response_update={"id": 42}) club_members = list(client.get_club_members(99)) assert club_members[0].is_authenticated_athlete() == expected_result stravalib-1.3.0/stravalib/tests/resources/000077500000000000000000000000001442076457100206575ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/resources/activity-manual.3.json000066400000000000000000000025151442076457100250250ustar00rootroot00000000000000{ "achievement_count": 0, "athlete": { "id": 1513, "resource_state": 1 }, "athlete_count": 1, "average_speed": 0.5821912144702842, "calories": 2084.438266172827, "comment_count": 0, "commute": false, "distance": 22530.8, "elapsed_time": 38700, "end_latlng": null, "external_id": null, "flagged": false, "gear_id": "g69911", "guid": "7143b325a3e3d6b8f7fc207952a1d03a023b4b9d", "has_kudoed": false, "id": 96089609, "kudos_count": 0, "location_city": "El Dorado County, CA, USA", "location_state": "CA", "manual": true, "map": { "id": "a96089609", "polyline": null, "resource_state": 3, "summary_polyline": null }, "max_speed": 0.0, "moving_time": 38700, "name": "Mt. Tallac Summit Sunday", "photo_count": 0, "total_photo_count": 0, "private": false, "resource_state": 3, "segment_efforts": [], "start_date": "2013-11-17T16:00:00Z", "start_date_local": "2013-11-17T08:00:00Z", "start_latitude": 38.74263759999999, "start_latlng": [ 38.74263759999999, -120.4357631 ], "start_longitude": -120.4357631, "timezone": "(GMT-08:00) America/Los_Angeles", "total_elevation_gain": 0, "trainer": false, "type": "Hike", "upload_id": null } stravalib-1.3.0/stravalib/tests/resources/activity.3.json000066400000000000000000000565231442076457100235620ustar00rootroot00000000000000{ "achievement_count": 9, "athlete": { "id": 1513, "resource_state": 1 }, "athlete_count": 2, "average_speed": 2.0, "best_efforts": [ { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 400, "elapsed_time": 134, "end_index": 204, "id": 464733311, "kom_rank": null, "moving_time": 136, "name": "400m", "pr_rank": null, "resource_state": 2, "segment": null, "start_date": "2013-12-12T19:40:29Z", "start_date_local": "2013-12-12T11:40:29Z", "start_index": 112 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 805, "elapsed_time": 279, "end_index": 299, "id": 464733312, "kom_rank": null, "moving_time": 281, "name": "1/2 mile", "pr_rank": 3, "resource_state": 2, "segment": null, "start_date": "2013-12-12T19:40:29Z", "start_date_local": "2013-12-12T11:40:29Z", "start_index": 112 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 1000, "elapsed_time": 355, "end_index": 340, "id": 464733313, "kom_rank": null, "moving_time": 356, "name": "1k", "pr_rank": 2, "resource_state": 2, "segment": null, "start_date": "2013-12-12T19:40:30Z", "start_date_local": "2013-12-12T11:40:30Z", "start_index": 113 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 1609, "elapsed_time": 656, "end_index": 478, "id": 464733314, "kom_rank": null, "moving_time": 602, "name": "1 mile", "pr_rank": 3, "resource_state": 2, "segment": null, "start_date": "2013-12-12T19:40:30Z", "start_date_local": "2013-12-12T11:40:30Z", "start_index": 113 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 3219, "elapsed_time": 1579, "end_index": 855, "id": 464733315, "kom_rank": null, "moving_time": 1382, "name": "2 miles", "pr_rank": 3, "resource_state": 2, "segment": null, "start_date": "2013-12-12T19:40:29Z", "start_date_local": "2013-12-12T11:40:29Z", "start_index": 112 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 5000, "elapsed_time": 2507, "end_index": 1158, "id": 464733316, "kom_rank": null, "moving_time": 2178, "name": "5k", "pr_rank": 2, "resource_state": 2, "segment": null, "start_date": "2013-12-12T19:36:41Z", "start_date_local": "2013-12-12T11:36:41Z", "start_index": 0 } ], "calories": 537.9, "comment_count": 1, "commute": false, "description": null, "distance": 5781.1, "elapsed_time": 3140, "end_latlng": [ 37.780877, -122.39548 ], "external_id": "620D5DDD-B836-4F6B-8760-F905B8C2895F", "flagged": false, "gear_id": "g69911", "guid": "620D5DDD-B836-4F6B-8760-F905B8C2895F", "has_kudoed": false, "id": 99895560, "kudos_count": 12, "location_city": "San Francisco", "location_state": "CA", "manual": false, "map": { "id": "a99895560", "polyline": "ubreFbk`jVTFJCLI^u@`@k@~@aAHUZg@b@c@P]hAyATa@d@e@PYh@_@p@cABGIMd@W\\[HOAK[a@@GPOl@u@PQXc@NO^o@V_AH[Be@CcAMm@Yk@I]EGCSWs@Oq@Ig@Wm@EYSeAOOIUMMICW@iAHOEW@OCoBLi@JS@_@C]BYFk@A]@UB[?_@Jg@X{@H[Dw@Cs@FMASIG?SBaBS{@@UI]@k@Gk@AQE[Cc@?SCS@s@A[Gi@?a@SDF?DiAOYKIFIG@NADWISA_BAcAD[IDAc@Fc@AOFO?[D[As@BSD[?a@Lq@JWNML[LYPo@n@]VqAl@ODgAj@CD{@Vm@^]d@UTMTi@t@QNQXQPKVg@d@o@z@[ZeA|AWVk@v@[\\ULGJQFU`@SHY`@UFWRAFa@NCBGf@QRc@PQr@KFK@SPHQJBLEDMBEF?BCHSFCHOf@[JSDA^]JO@GZ]D@BSFQ\\KBE?IDCR?VSDG@Of@c@?EHKFKXSNW\\YBM^Y?GNMBMh@m@f@{@RQLWdAmAf@i@PKHQRSLYVKVYPM\\GZONANMLG^Ih@a@j@M`@YJMLYf@_@D@PCXBX_@TKHMPBNAVEPIb@DZCJDRCNDNCNBVOXAVDPENAx@Jv@Bp@Jz@@z@LfAELDXAr@PDCECHZ@RIZARBXFPV\\BJ\\NVd@HBPL@NLPHDDLVHV`@DZBFPJFTLRDBJEBEXC|@j@NDJXPPBLLLNXVp@B^Lr@@b@J\\n@h@DBJ?DD?JOf@W`@CRKPGFQFa@XINERDXBQBDPARGVED@RRBFEA?PD?@DDGFAD@DDL@VJVTZ\\DJLDDL?LBFf@x@RTNHDt@Xh@PH@DQp@KRA^YlA[b@By@e@GEDLPv@^OBXCTGJG^q@AGIEYIGP", "resource_state": 3, "summary_polyline": "oyqeFz``jVvGsItDsIiEwQsXbBaVcByQz@wLjHkWzYzYw[vLcGbGg@hOz@?jClFjHvBf@hErIyBnFjCR" }, "max_speed": 8.3, "moving_time": 2892, "name": "Lunch Rover Shuffle-Walk-Yog with Todd", "photo_count": 0, "total_photo_count": 0, "private": false, "resource_state": 3, "segment_efforts": [ { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 172.8, "elapsed_time": 108, "end_index": 113, "id": 2137250436, "kom_rank": null, "moving_time": 61, "name": "Sprint to catch light", "pr_rank": null, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": -1.8, "city": "San Francisco", "climb_category": 0, "distance": 124.35, "elevation_high": 6.8, "elevation_low": 4.5, "end_latitude": 37.7782023511827, "end_latlng": [ 37.7782023511827, -122.39171963185072 ], "end_longitude": -122.39171963185072, "id": 3866093, "maximum_grade": -0.0, "name": "Sprint to catch light", "pr_time": 108, "private": false, "resource_state": 2, "start_latitude": 37.778987400233746, "start_latlng": [ 37.778987400233746, -122.39271523430943 ], "start_longitude": -122.39271523430943, "state": "CA" }, "start_date": "2013-12-12T19:38:42Z", "start_date_local": "2013-12-12T11:38:42Z", "start_index": 76 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 773.2, "elapsed_time": 273, "end_index": 327, "id": 2137250444, "kom_rank": null, "moving_time": 273, "name": "AT&T 800m Dash", "pr_rank": 1, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": -0.0, "city": "San Francisco", "climb_category": 0, "distance": 785.3, "elevation_high": 4.1, "elevation_low": 0.0, "end_latitude": 37.782742, "end_latlng": [ 37.782742, -122.387922 ], "end_longitude": -122.387922, "id": 5666311, "maximum_grade": 18.1, "name": "AT&T 800m Dash", "pr_time": 273, "private": false, "resource_state": 2, "start_latitude": 37.777171, "start_latlng": [ 37.777171, -122.390329 ], "start_longitude": -122.390329, "state": "CA" }, "start_date": "2013-12-12T19:41:27Z", "start_date_local": "2013-12-12T11:41:27Z", "start_index": 151 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 257.5, "elapsed_time": 88, "end_index": 211, "id": 2137250440, "kom_rank": null, "moving_time": 88, "name": "AT&T Park Backside", "pr_rank": 1, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": 0.0, "city": "San Francisco", "climb_category": 0, "distance": 249.397, "elevation_high": 6.2, "elevation_low": 6.2, "end_latitude": 37.778352638706565, "end_latlng": [ 37.778352638706565, -122.38767142407596 ], "end_longitude": -122.38767142407596, "id": 1808371, "maximum_grade": 0.0, "name": "AT&T Park Backside", "pr_time": 88, "private": false, "resource_state": 2, "start_latitude": 37.777267936617136, "start_latlng": [ 37.777267936617136, -122.39012941718102 ], "start_longitude": -122.39012941718102, "state": "CA" }, "start_date": "2013-12-12T19:41:31Z", "start_date_local": "2013-12-12T11:41:31Z", "start_index": 155 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 1768.4, "elapsed_time": 741, "end_index": 562, "id": 2137250448, "kom_rank": null, "moving_time": 689, "name": "Mile By the Bay ", "pr_rank": null, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": -0.0, "city": "San Francisco", "climb_category": 0, "distance": 1689.36, "elevation_high": 4.2, "elevation_low": 3.0, "end_latitude": 37.79156947508454, "end_latlng": [ 37.79156947508454, -122.38967721350491 ], "end_longitude": -122.38967721350491, "id": 2552268, "maximum_grade": 0.6, "name": "Mile By the Bay ", "pr_time": 741, "private": false, "resource_state": 2, "start_latitude": 37.777588460594416, "start_latlng": [ 37.777588460594416, -122.38948199898005 ], "start_longitude": -122.38948199898005, "state": "CA" }, "start_date": "2013-12-12T19:41:52Z", "start_date_local": "2013-12-12T11:41:52Z", "start_index": 172 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 322.5, "elapsed_time": 115, "end_index": 288, "id": 2137250442, "kom_rank": null, "moving_time": 115, "name": "Pier 42 to Pier 40 Beer Dash", "pr_rank": 1, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": 0.2, "city": "San Francisco", "climb_category": 0, "distance": 318.09, "elevation_high": 4.4, "elevation_low": 3.5, "end_latitude": 37.781130066141486, "end_latlng": [ 37.781130066141486, -122.38788725808263 ], "end_longitude": -122.38788725808263, "id": 3394958, "maximum_grade": 0.9, "name": "Pier 42 to Pier 40 Beer Dash", "pr_time": 115, "private": false, "resource_state": 2, "start_latitude": 37.7783128246665, "start_latlng": [ 37.7783128246665, -122.38754871301353 ], "start_longitude": -122.38754871301353, "state": "CA" }, "start_date": "2013-12-12T19:42:59Z", "start_date_local": "2013-12-12T11:42:59Z", "start_index": 211 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 1678.8, "elapsed_time": 790, "end_index": 672, "id": 2137250456, "kom_rank": null, "moving_time": 714, "name": "AT&T Park to Ferry Building Scenic Mile", "pr_rank": null, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": -0.0, "city": "San Francisco", "climb_category": 0, "distance": 1596.4, "elevation_high": 3.7, "elevation_low": 2.8, "end_latitude": 37.7944156, "end_latlng": [ 37.7944156, -122.3931068 ], "end_longitude": -122.3931068, "id": 825637, "maximum_grade": 0.7, "name": "AT&T Park to Ferry Building Scenic Mile", "pr_time": 790, "private": false, "resource_state": 2, "start_latitude": 37.7814597, "start_latlng": [ 37.7814597, -122.3882274 ], "start_longitude": -122.3882274, "state": "CA" }, "start_date": "2013-12-12T19:45:08Z", "start_date_local": "2013-12-12T11:45:08Z", "start_index": 298 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 1755.4, "elapsed_time": 861, "end_index": 695, "id": 2137250458, "kom_rank": null, "moving_time": 781, "name": "south beach speed mile", "pr_rank": null, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": -0.0, "city": "San Francisco", "climb_category": 0, "distance": 1657.4, "elevation_high": 3.9, "elevation_low": 2.4, "end_latitude": 37.795105, "end_latlng": [ 37.795105, -122.393717 ], "end_longitude": -122.393717, "id": 4878224, "maximum_grade": 0.8, "name": "south beach speed mile", "pr_time": 861, "private": false, "resource_state": 2, "start_latitude": 37.78152, "start_latlng": [ 37.78152, -122.388018 ], "start_longitude": -122.388018, "state": "CA" }, "start_date": "2013-12-12T19:45:10Z", "start_date_local": "2013-12-12T11:45:10Z", "start_index": 299 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 1628.4, "elapsed_time": 770, "end_index": 672, "id": 2137250457, "kom_rank": null, "moving_time": 694, "name": "Townsend Mile", "pr_rank": null, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": 0.3, "city": "San Francisco", "climb_category": 0, "distance": 1600.1, "elevation_high": 4.2, "elevation_low": -0.4, "end_latitude": 37.794483, "end_latlng": [ 37.794483, -122.393248 ], "end_longitude": -122.393248, "id": 4850732, "maximum_grade": 21.2, "name": "Townsend Mile", "pr_time": 770, "private": false, "resource_state": 2, "start_latitude": 37.781929, "start_latlng": [ 37.781929, -122.388087 ], "start_longitude": -122.388087, "state": "CA" }, "start_date": "2013-12-12T19:45:28Z", "start_date_local": "2013-12-12T11:45:28Z", "start_index": 308 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 286.2, "elapsed_time": 160, "end_index": 665, "id": 2137250453, "kom_rank": null, "moving_time": 136, "name": "Gotta Catch That Ferry Home!", "pr_rank": null, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": 0.1, "city": "San Francisco", "climb_category": 0, "distance": 331.264, "elevation_high": 3.2, "elevation_low": 2.5, "end_latitude": 37.79453448954764, "end_latlng": [ 37.79453448954764, -122.3928627559192 ], "end_longitude": -122.3928627559192, "id": 790791, "maximum_grade": 1.6, "name": "Gotta Catch That Ferry Home!", "pr_time": 160, "private": false, "resource_state": 2, "start_latitude": 37.79226324524524, "start_latlng": [ 37.79226324524524, -122.3912170530505 ], "start_longitude": -122.3912170530505, "state": "CA" }, "start_date": "2013-12-12T19:55:23Z", "start_date_local": "2013-12-12T11:55:23Z", "start_index": 597 }, { "activity": { "id": 99895560 }, "athlete": { "id": 1513 }, "distance": 552.3, "elapsed_time": 245, "end_index": 1014, "id": 2137250459, "kom_rank": null, "moving_time": 245, "name": "Harrison to Brannan on Embarcadero", "pr_rank": 2, "resource_state": 2, "segment": { "activity_type": "Run", "average_grade": 0.1, "city": "San Francisco", "climb_category": 0, "distance": 542.2, "elevation_high": 3.9, "elevation_low": 3.0, "end_latitude": 37.784975, "end_latlng": [ 37.784975, -122.387799 ], "end_longitude": -122.387799, "id": 2856187, "maximum_grade": 3.6, "name": "Harrison to Brannan on Embarcadero", "pr_time": 219, "private": false, "resource_state": 2, "start_latitude": 37.789698, "start_latlng": [ 37.789698, -122.388244 ], "start_longitude": -122.388244, "state": "CA" }, "start_date": "2013-12-12T20:08:00Z", "start_date_local": "2013-12-12T12:08:00Z", "start_index": 892 } ], "splits_metric": [ { "distance": 1002.9, "elapsed_time": 395, "elevation_difference": -2.0, "moving_time": 369, "split": 1 }, { "distance": 1000.0, "elapsed_time": 429, "elevation_difference": -0.1, "moving_time": 429, "split": 2 }, { "distance": 998.7, "elapsed_time": 482, "elevation_difference": -0.5, "moving_time": 482, "split": 3 }, { "distance": 1000.2, "elapsed_time": 620, "elevation_difference": 0.6, "moving_time": 620, "split": 4 }, { "distance": 1000.3, "elapsed_time": 583, "elevation_difference": 7.5, "moving_time": 508, "split": 5 }, { "distance": 779.0, "elapsed_time": 631, "elevation_difference": -5.3, "moving_time": 487, "split": 6 } ], "splits_standard": [ { "distance": 1613.2, "elapsed_time": 622, "elevation_difference": -2.2, "moving_time": 596, "split": 1 }, { "distance": 1608.2, "elapsed_time": 893, "elevation_difference": -0.5, "moving_time": 893, "split": 2 }, { "distance": 1609.2, "elapsed_time": 926, "elevation_difference": 3.4, "moving_time": 851, "split": 3 }, { "distance": 950.5, "elapsed_time": 699, "elevation_difference": -0.5, "moving_time": 555, "split": 4 } ], "start_date": "2013-12-12T19:36:41Z", "start_date_local": "2013-12-12T11:36:41Z", "start_latitude": 37.781076, "start_latlng": [ 37.781076, -122.395536 ], "start_longitude": -122.395536, "timezone": "(GMT-08:00) America/Los_Angeles", "total_elevation_gain": 13.5, "trainer": false, "type": "Run", "upload_id": 109244893, "workout_type": null } stravalib-1.3.0/stravalib/tests/resources/athlete.2.json000066400000000000000000000037711442076457100233500ustar00rootroot00000000000000{ "all_ride_totals": { "count": 8, "distance": 548551, "elapsed_time": 81493, "elevation_gain": 9860, "moving_time": 81493 }, "all_run_totals": { "count": 3, "distance": 47052, "elapsed_time": 15976, "elevation_gain": 752, "moving_time": 15976 }, "approve_followers": true, "athlete_type": 0, "biggest_climb_elevation_gain": 3016.7999999999997, "biggest_ride_distance": 123285.0, "city": "San Francisco", "created_at": "2012-01-18T18:20:37Z", "date_preference": "%m/%d/%Y", "email_language": "en-US", "firstname": "John", "follower": "accepted", "follower_count": 0, "friend": null, "friend_count": 34, "id": 227615, "lastname": "Applestrava", "measurement_preference": "feet", "mutual_friend_count": 27, "premium": true, "profile": "http://dgalywyr863hv.cloudfront.net/pictures/athletes/227615/41555/3/large.jpg", "profile_medium": "http://dgalywyr863hv.cloudfront.net/pictures/athletes/227615/41555/3/medium.jpg", "profile_original": "http://dgalywyr863hv.cloudfront.net/pictures/athletes/227615/41555/3/original.jpg", "recent_ride_totals": { "achievement_count": 0, "count": 0, "distance": 0.0, "elapsed_time": 0, "elevation_gain": 0.0, "moving_time": 0 }, "recent_run_totals": { "achievement_count": 0, "count": 0, "distance": 0.0, "elapsed_time": 0, "elevation_gain": 0.0, "moving_time": 0 }, "resource_state": 3, "sex": "M", "state": "CA", "super_user": false, "updated_at": "2013-01-08T19:08:21Z", "ytd_ride_totals": { "count": 1, "distance": 32998, "elapsed_time": 4977, "elevation_gain": 362, "moving_time": 4977 }, "ytd_run_totals": { "count": 0, "distance": 0, "elapsed_time": 0, "elevation_gain": 0, "moving_time": 0 } } stravalib-1.3.0/stravalib/tests/resources/athlete.3.json000066400000000000000000000102151442076457100233400ustar00rootroot00000000000000{ "agreed_to_terms": true, "all_ride_totals": { "count": 266, "distance": 7401423, "elapsed_time": 1349820, "elevation_gain": 73649, "moving_time": 1348305 }, "all_run_totals": { "count": 5, "distance": 47045, "elapsed_time": 15925, "elevation_gain": 50, "moving_time": 15295 }, "approve_followers": true, "athlete_type": 1, "biggest_climb_elevation_gain": 474.6, "biggest_ride_distance": 152127.0, "bikes": [ { "distance": 1981531.2, "id": "b55763", "name": "Cannondale SuperSix HI-MOD 2 Red", "primary": false, "resource_state": 2 }, { "distance": 176435.9, "id": "b809124", "name": "LOOK 986", "primary": true, "resource_state": 2 }, { "distance": 191201.9, "id": "b58057", "name": "Niner One 9", "primary": false, "resource_state": 2 }, { "distance": 82744.3, "id": "b979998", "name": "Specialized Langster Pro", "primary": false, "resource_state": 2 } ], "city": "Nicasio", "clubs": [ { "id": 7, "name": "Team Roaring Mouse", "resource_state": 2 }, { "id": 1, "name": "Team Strava Cycling", "resource_state": 2 }, { "id": 34444, "name": "Team Strava Cyclocross", "resource_state": 2 } ], "created_at": "2009-09-22T18:24:06Z", "date_preference": "%m/%d/%Y", "dateofbirth": "1985-01-11", "description": "iOS Software Engineer at Strava.", "email": "jeff@strava.com", "email_facebook_twitter_friend_joins": false, "email_kom_lost": false, "email_language": "en-US", "email_send_follower_notices": false, "facebook_sharing_enabled": true, "firstname": "Jeff", "follower": null, "follower_count": 106, "follower_request_count": 0, "friend": null, "friend_count": 115, "ftp": null, "global_privacy": 1, "id": 1513, "instagram_username": "jeffremer", "lastname": "Remer", "max_heartrate": 198, "measurement_preference": "feet", "mutual_friend_count": 0, "offer_in_app_payment": false, "plan": "paid", "premium": true, "premium_expiration_date": 1390896000, "profile": "http://dgalywyr863hv.cloudfront.net/pictures/athletes/1513/692280/5/large.jpg", "profile_medium": "http://dgalywyr863hv.cloudfront.net/pictures/athletes/1513/692280/5/medium.jpg", "profile_original": "http://dgalywyr863hv.cloudfront.net/pictures/athletes/1513/692280/5/original.jpg", "receive_comment_emails": false, "receive_follower_feed_emails": true, "receive_kudos_emails": false, "receive_newsletter": true, "recent_ride_totals": { "achievement_count": 0, "count": 0, "distance": 0.0, "elapsed_time": 0, "elevation_gain": 0.0, "moving_time": 0 }, "recent_run_totals": { "achievement_count": 9, "count": 1, "distance": 5781.10009765625, "elapsed_time": 3140, "elevation_gain": 13.504196166992188, "moving_time": 2892 }, "resource_state": 3, "sample_race_distance": 10000, "sample_race_time": 5400, "sex": "M", "shoes": [ { "distance": 9045.8, "id": "g69911", "name": "Salomon XT Wings 2", "primary": true, "resource_state": 2 } ], "state": "California", "super_user": true, "updated_at": "2013-12-18T01:24:27Z", "username": "jeffremer", "weight": 75.2963, "ytd_ride_totals": { "count": 20, "distance": 585559, "elapsed_time": 126841, "elevation_gain": 8024, "moving_time": 126841 }, "ytd_run_totals": { "count": 1, "distance": 5782, "elapsed_time": 2892, "elevation_gain": 14, "moving_time": 2892 } } stravalib-1.3.0/stravalib/tests/resources/example_route_response.json000066400000000000000000000356731442076457100263570ustar00rootroot00000000000000{ "athlete": { "id": 42424242, "username": "foobar", "resource_state": 2, "firstname": "Foo", "lastname": "Bar", "bio": null, "city": null, "state": null, "country": null, "sex": "M", "premium": true, "summit": true, "created_at": "2014-03-22T12:54:16Z", "updated_at": "2023-01-13T09:20:30Z", "badge_type_id": 1, "weight": 65.0, "profile_medium": null, "profile": null, "friend": null, "follower": null }, "description": "", "distance": 15115.193361202424, "elevation_gain": 24.119343701388587, "id": 23895346, "id_str": "23895346", "map": { "id": "r23895346", "summary_polyline": "{~e}H}gz_@jAWb@WfB_BTG\\DhBiChBwCTUJa@?_@I]BKfAy@dFiDpHqDF?Ld@dBq@rCmBRQnAu@tCgAjEsCbEyCfRsK~MyFfDkBLM`JteBNaAvFwWrCkMfF_UPi@bF{TLo@t@sIlOhAxAN`Dn@vKrC~E`BfDf@l@d@bAdBvAdBnNpIw@vLg@rJYbDBNdErK`MvZdB~DjA`CnAbBhCrElHdHf@p@xBtBdBjBhInOrFtJbApCKnJgA`GMtAYG}BOuACiDHcCN_B@aMw@eLc@cOu@k@?eQjDq@ReAb@{Cl@}@FmDx@iNnCoAP}AFwBo@y@I{AUgImCkE{AaEoAkCk@_E_@cNS_AQcHwBmF{A}Bi@oAc@_Bs@_AQqBSg@Q_@WuCwCyBeCFUCKeAuAgCsCKUmFcb@QaDOg@PSFQHaAG_@mBqFu@qDe@cDTKBMHGfASHM\\uAh@[A?|@c@FI@YSqD@_@f@gB\\y@p@wBt@wCZo@Pg@ZoBP[@WJa@HMZMcBcG?g@aAmCeA}Bo@y@IP}@gBg@s@sClBeBp@Me@G?cBv@mExBaG~D_@Yc@e@oDa\\_JlEwCx@", "resource_state": 3, "polyline": "{~e}H}gz_@jAWb@WfB_BTG\\DhBiChBwCTUJa@?_@I]BKfAy@dFiDpHqDF?Ld@dBq@rCmBRQnAu@tCgAjEsCbEyCfRsK~MyFfDkBLM`JteBNaAvFwWrCkMfF_UPi@bF{TLo@t@sIlOhAxAN`Dn@vKrC~E`BfDf@l@d@bAdBvAdBnNpIw@vLg@rJYbDBNdErK`MvZdB~DjA`CnAbBhCrElHdHf@p@xBtBdBjBhInOrFtJbApCKnJgA`GMtAYG}BOuACiDHcCN_B@aMw@eLc@cOu@k@?eQjDq@ReAb@{Cl@}@FmDx@iNnCoAP}AFwBo@y@I{AUgImCkE{AaEoAkCk@_E_@cNS_AQcHwBmF{A}Bi@oAc@_Bs@_AQqBSg@Q_@WuCwCyBeCFUCKeAuAgCsCKUmFcb@QaDOg@PSFQHaAG_@mBqFu@qDe@cDTKBMHGfASHM\\uAh@[A?|@c@FI@YSqD@_@f@gB\\y@p@wBt@wCZo@Pg@ZoBP[@WJa@HMZMcBcG?g@aAmCeA}Bo@y@IP}@gBg@s@sClBeBp@Me@G?cBv@mExBaG~D_@Yc@e@oDa\\_JlEwCx@" }, "map_urls": { "url": "https://d3o5xota0a1fcr.cloudfront.net/v6/maps/Q3LT7TUSJGLDPHNKMKCD4VRX4ZZWENILSWXXN7GSWMW7FWCC3CAC46GWXZ6ISCRDS5LUL666K3J2NXUJUC5SRFK3STRQ====", "retina_url": "https://d3o5xota0a1fcr.cloudfront.net/v6/maps/ITVROAQQNZR62HQYXAJLIZAZMELM54C3OGMCXJ76YABIOJCGCNHBP5L4U5VQGRWKFKAJWEAZI7SED65RNRD2ACR5BFHA====" }, "name": "15k, no traffic", "private": false, "resource_state": 3, "starred": false, "sub_type": 1, "created_at": "2020-02-15T07:26:24Z", "updated_at": "2023-01-25T07:03:47Z", "timestamp": 1581751584, "type": 2, "estimated_moving_time": 4609, "segments": [ { "id": 5908898, "resource_state": 2, "name": "Paradijsweg", "activity_type": "Run", "distance": 1131.6, "average_grade": -0.2, "maximum_grade": 6.3, "elevation_high": 16.0, "elevation_low": 8.4, "start_latlng": [ 52.129719, 5.388209 ], "end_latlng": [ 52.120482, 5.394089 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "UT", "country": "The Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 5909010, "resource_state": 2, "name": "nimmerdor treek", "activity_type": "Run", "distance": 1908.1, "average_grade": -0.2, "maximum_grade": 5.4, "elevation_high": 17.8, "elevation_low": 8.5, "start_latlng": [ 52.136221, 5.382968 ], "end_latlng": [ 52.120943, 5.393756 ], "elevation_profile": null, "climb_category": 0, "city": "Amersfoort", "state": "UT", "country": "The Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 6038825, "resource_state": 2, "name": "Treekerweg", "activity_type": "Run", "distance": 790.2, "average_grade": 0.2, "maximum_grade": 2.5, "elevation_high": 9.9, "elevation_low": 8.6, "start_latlng": [ 52.113625, 5.393466 ], "end_latlng": [ 52.10719, 5.391807 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 7845371, "resource_state": 2, "name": "Viaduct Nimmerdor A28 N-Z", "activity_type": "Run", "distance": 300.3, "average_grade": 0.3, "maximum_grade": 5.4, "elevation_high": 14.7, "elevation_low": 8.7, "start_latlng": [ 52.133993, 5.385814 ], "end_latlng": [ 52.131475, 5.387323 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "UT", "country": "The Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 7845381, "resource_state": 2, "name": "Viaduct Nimmerdor A28 Z-N", "activity_type": "Run", "distance": 333.0, "average_grade": -0.2, "maximum_grade": 4.7, "elevation_high": 15.2, "elevation_low": 8.6, "start_latlng": [ 52.131352, 5.387419 ], "end_latlng": [ 52.134134, 5.385679 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "UT", "country": "The Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 8566739, "resource_state": 2, "name": "Doornse Grindweg - fietspad", "activity_type": "Run", "distance": 2014.9, "average_grade": -0.0, "maximum_grade": 3.2, "elevation_high": 18.0, "elevation_low": 11.8, "start_latlng": [ 52.092348, 5.362292 ], "end_latlng": [ 52.110293, 5.36069 ], "elevation_profile": null, "climb_category": 0, "city": "Woudenberg", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 9800119, "resource_state": 2, "name": "Kerkweg", "activity_type": "Run", "distance": 628.9, "average_grade": -0.9, "maximum_grade": 2.9, "elevation_high": 15.0, "elevation_low": 8.5, "start_latlng": [ 52.127515, 5.366518 ], "end_latlng": [ 52.129971, 5.374385 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 11980735, "resource_state": 2, "name": "Treekerweg Paaltjes tot Paaltjes", "activity_type": "Run", "distance": 1844.2, "average_grade": 0.1, "maximum_grade": 5.0, "elevation_high": 19.7, "elevation_low": 10.8, "start_latlng": [ 52.102526, 5.388294 ], "end_latlng": [ 52.093437, 5.368515 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 15380426, "resource_state": 2, "name": "Trekerweg", "activity_type": "Run", "distance": 212.4, "average_grade": 0.5, "maximum_grade": 1.4, "elevation_high": 17.0, "elevation_low": 16.0, "start_latlng": [ 52.093129, 5.368144 ], "end_latlng": [ 52.091816, 5.36605 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 22491920, "resource_state": 2, "name": "Ooiervaarshorstlaan", "activity_type": "Run", "distance": 1149.7, "average_grade": -0.5, "maximum_grade": 2.6, "elevation_high": 17.2, "elevation_low": 7.9, "start_latlng": [ 52.118442, 5.378588 ], "end_latlng": [ 52.113753, 5.393267 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 23698584, "resource_state": 2, "name": "400m Slagboom Keesomstraat tot aan viadcut ", "activity_type": "Run", "distance": 386.3, "average_grade": 0.1, "maximum_grade": 1.5, "elevation_high": 11.6, "elevation_low": 11.0, "start_latlng": [ 52.137145, 5.382523 ], "end_latlng": [ 52.134375, 5.385398 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 30236892, "resource_state": 2, "name": "Sprinten naar het tankstation", "activity_type": "Run", "distance": 335.2, "average_grade": -0.1, "maximum_grade": 1.0, "elevation_high": 12.1, "elevation_low": 11.2, "start_latlng": [ 52.123585, 5.363975 ], "end_latlng": [ 52.126445, 5.365075 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 30818657, "resource_state": 2, "name": "Mountainbikers ontwijken", "activity_type": "Run", "distance": 260.1, "average_grade": -0.1, "maximum_grade": 2.6, "elevation_high": 13.4, "elevation_low": 12.6, "start_latlng": [ 52.0999, 5.377475 ], "end_latlng": [ 52.098253, 5.374693 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 31055810, "resource_state": 2, "name": "Wat een dooie boel hier", "activity_type": "Run", "distance": 179.1, "average_grade": -0.2, "maximum_grade": 0.9, "elevation_high": 11.5, "elevation_low": 10.9, "start_latlng": [ 52.12994, 5.375362 ], "end_latlng": [ 52.130797, 5.37817 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 31099653, "resource_state": 2, "name": "Zwaaien naar de horeca", "activity_type": "Run", "distance": 438.7, "average_grade": 0.0, "maximum_grade": 0.6, "elevation_high": 12.9, "elevation_low": 12.5, "start_latlng": [ 52.11288, 5.360848 ], "end_latlng": [ 52.11667, 5.362385 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false }, { "id": 31263331, "resource_state": 2, "name": "Vals plat sprint", "activity_type": "Run", "distance": 389.7, "average_grade": -0.3, "maximum_grade": 1.2, "elevation_high": 13.3, "elevation_low": 11.9, "start_latlng": [ 52.117255, 5.36258 ], "end_latlng": [ 52.120722, 5.362687 ], "elevation_profile": null, "climb_category": 0, "city": "Leusden", "state": "Utrecht", "country": "Netherlands", "private": false, "hazardous": false, "starred": false } ] } stravalib-1.3.0/stravalib/tests/resources/example_zone_response.json000066400000000000000000000033301442076457100261550ustar00rootroot00000000000000[ { "score": 28.0, "distribution_buckets": [ { "min": 0, "max": 120, "time": 589 }, { "min": 120, "max": 149, "time": 2732 }, { "min": 149, "max": 164, "time": 0 }, { "min": 164, "max": 179, "time": 0 }, { "min": 179, "max": -1, "time": 0 } ], "type": "heartrate", "resource_state": 3, "sensor_based": true, "points": 0, "custom_zones": false }, { "score": 4, "distribution_buckets": [ { "max": 3.319925953425268, "min": 0, "time": 2042 }, { "max": 3.8569727988322966, "min": 3.319925953425268, "time": 285 }, { "max": 4.296374763256229, "min": 3.8569727988322966, "time": 870 }, { "max": 4.589309406205517, "min": 4.296374763256229, "time": 121 }, { "max": 4.882244049154806, "min": 4.589309406205517, "time": 3 }, { "max": -1, "min": 4.882244049154806, "time": 0 } ], "type": "pace", "resource_state": 3, "sensor_based": true } ] stravalib-1.3.0/stravalib/tests/resources/sample.tcx000066400000000000000000015617671442076457100227070ustar00rootroot00000000000000 2009-02-21T12:57:25Z 18.645.3164786.7650000 81 96 ActiveLocation -90.5360.000 70 -91.1810.000 76 -91.6090.000 81 -91.1230.000 87 -91.6780.000 90 52.1485144.500887 -91.7315.316 93 8168.5864664.87109411.1399992371 137 165 ActiveManual 52.1483264.500603 -90.79534.314 98 -90.98175.185 105 -91.408120.276 108 52.1474204.500992 -90.549187.704 108 52.1472764.501159 -90.845207.269 108 52.1469184.501408 -89.691250.563 112 -88.002351.016 116 -88.807365.969 118 -90.384404.661 114 52.1455114.501028 -89.606496.550 119 -88.306595.279 121 52.1450544.499456 -88.083617.242 121 52.1449594.499014 -86.080653.732 119 52.1448864.498988 -84.689662.360 115 52.1448734.499016 -83.897666.266 89 52.1448504.499045 -84.188669.897 89 52.1447624.499182 -84.003682.863 91 52.1445984.499638 -84.017719.079 99 52.1445754.499735 -84.371726.232 105 52.1444804.500130 -86.311755.320 107 52.1442724.500675 -88.188799.698 106 52.1439194.501586 -88.085873.543 108 52.1438544.501824 -87.567890.863 108 52.1435484.502766 -87.152964.460 109 52.1432324.503736 -86.6251039.606 115 52.1424524.506057 -86.5351220.746 118 52.1419814.507488 -84.7071331.908 118 52.1413594.509149 -85.2771465.209 119 52.1409604.510186 -82.6201549.229 121 52.1408764.510336 -82.7521563.096 121 52.1406774.511251 -82.8281629.651 128 52.1406464.511362 -82.4281637.967 128 52.1404724.511900 -81.9891679.624 123 52.1401004.513061 -81.5211769.248 118 52.1396854.514491 -83.5321877.682 117 52.1396474.514610 -83.4511886.705 117 52.1394974.515338 -82.4541939.348 115 52.1393254.515894 -82.8101981.955 107 52.1392564.516111 -82.9551998.707 106 52.1393334.516797 -81.4932048.878 108 52.1402464.518509 -81.1272204.151 110 52.1410864.520136 -81.3582349.582 110 52.1411424.520215 -81.1612357.843 110 52.1412044.520821 -80.9522405.136 108 52.1411094.521361 -79.7722448.595 105 52.1411534.521451 -79.9412456.532 106 52.1419724.523011 -79.4952596.904 105 52.1427074.524274 -77.7182716.174 100 52.1427474.524329 -77.6242722.406 99 52.1429054.524631 -76.8052749.359 93 52.1430824.525016 -77.2442782.134 93 52.1432544.525424 -77.0952816.088 95 52.1433614.525766 -76.7352842.259 100 52.1432654.526445 -77.5782894.013 103 52.1430324.526761 -77.2392927.763 110 52.1428694.527006 -76.9112952.496 118 52.1428164.527088 -77.1192960.720 118 52.1424104.527760 -75.3583025.138 124 52.1423114.527918 -75.5273040.530 128 52.1416614.529141 -75.8463151.371 126 52.1410564.530803 -75.5893283.686 125 52.1405774.532182 -75.0863392.252 126 52.1399924.533714 -73.6323515.988 124 52.1399214.534043 -73.1713539.803 124 52.1396054.534913 -71.4303609.093 124 52.1393684.535417 -71.3663654.894 119 52.1395264.536026 -71.3933701.070 120 52.1396464.537370 -71.2603794.403 126 52.1398234.539524 -72.5883943.419 123 52.1397974.540062 -72.6563980.079 128 52.1397744.540326 -72.3233998.659 132 52.1397534.540593 -72.2974017.021 127 52.1397424.540726 -72.2974026.297 127 52.1397104.541129 -72.4774054.110 120 52.1396994.541263 -72.1704063.380 120 52.1393534.543694 -71.6674234.552 117 52.1389784.545010 -71.1494333.926 122 52.1381674.547478 -70.9474525.310 126 52.1372314.549654 -70.3204707.600 127 52.1361244.551542 -69.9834886.213 129 52.1351174.552922 -69.4715032.781 125 52.1349134.553076 -70.6685057.787 130 52.1348424.553108 -70.1085066.005 132 52.1347084.553202 -70.1355082.358 135 52.1344894.553508 -68.6235114.800 130 52.1344514.553615 -68.4605123.256 129 52.1338324.554705 -68.2815225.490 128 52.1329404.556074 -68.2615361.803 127 52.1324784.556921 -67.5075439.812 128 52.1316154.558497 -66.7055584.483 130 52.1309924.559868 -66.5605701.334 132 52.1305214.561028 -66.8345796.571 127 52.1297844.563019 -64.9805955.758 134 52.1291864.565339 -65.3896128.216 133 52.1287694.567553 -63.4776286.723 132 52.1287194.567801 -64.2826304.605 131 52.1284504.569438 -63.7126421.055 131 52.1284444.569697 -63.4026438.795 129 52.1281164.572764 -62.8946651.950 129 52.1279744.574058 -62.3746741.989 136 52.1279444.574318 -61.8856760.140 143 52.1279294.574449 -61.7096769.260 142 52.1278754.575113 -61.7706815.066 137 52.1278004.576967 -62.3426942.394 132 52.1277954.577100 -62.1076951.250 131 52.1277004.579857 -60.2087140.495 129 52.1277964.582147 -60.3857297.937 133 52.1277174.582558 -60.1877327.771 130 52.1276834.582854 -60.0337348.758 124 52.1277084.582952 -59.9597356.007 124 52.1279944.583315 -59.0877396.836 124 52.1280824.583550 -59.7287415.630 125 52.1282684.584148 -60.0037461.580 133 52.1288264.586422 -58.9787629.251 128 52.1290624.587609 -57.8347714.833 124 52.1292354.588214 -58.2017767.653 123 52.1293944.588470 -58.2287792.925 124 52.1296764.589124 -57.9557847.519 129 52.1303614.590981 -56.3337996.001 125 52.1304574.591287 -56.2008019.518 120 52.1305144.591509 -55.4388036.204 117 52.1306204.591495 -56.5268050.202 117 52.1309514.591563 -56.3558090.327 119 52.1310084.591552 -56.1268096.652 119 52.1311314.591513 -56.2388110.682 119 52.1314524.591305 -55.7138149.136 125 52.1315704.591208 -55.3828163.963 132 52.1316404.591137 -55.0328172.935 138 52.1317144.591088 -55.6438181.800 138 52.1319924.590968 -55.1268216.721 129 52.1320584.591054 -55.2798226.029 129 52.1321004.591160 -55.1528234.732 129 52.1323594.591989 -56.0358298.142 132 52.1324654.592628 -56.6298343.645 134 52.1323304.593867 -55.2848430.406 129 52.1322064.595270 -55.1378527.706 127 52.1325494.595651 -55.5348580.963 128 52.1331284.595627 -55.7618645.298 129 52.1339124.594921 -57.0718745.694 133 52.1340404.594791 -56.6808762.483 132 52.1344934.594867 -53.6398817.478 128 52.1345384.594938 -53.7168824.747 127 52.1346164.595095 -53.3708838.153 124 52.1346634.595091 -52.9458843.869 120 52.1347694.594961 -54.6818858.627 119 52.1348624.594839 -56.6638871.998 120 52.1351924.594459 -55.0508917.017 122 52.1354164.594321 -54.6798943.991 121 52.1361154.593739 -56.0169032.830 125 52.1368214.592978 -55.5689127.252 127 52.1371614.592601 -55.6239172.967 133 52.1384854.591147 -55.4889350.871 137 52.1396734.589821 -54.3639511.209 137 52.1408284.588928 -53.7569653.528 137 52.1414094.588519 -53.8979724.176 135 52.1419874.588512 -52.5859791.201 133 52.1423684.588595 -53.5139835.681 133 52.1437534.587638 -53.08810003.095 133 52.1450324.586770 -52.50310157.333 134 52.1464744.585751 -52.35510332.382 140 52.1478724.584760 -52.21510502.026 138 52.1481544.584554 -51.78910536.479 143 52.1485884.584244 -51.30610589.218 150 52.1491854.583827 -51.22610661.515 145 52.1505994.582851 -50.53710832.204 142 52.1517504.582038 -49.83010972.026 140 52.1527074.581303 -49.52611089.981 137 52.1529304.581040 -49.25211120.575 133 52.1532414.580777 -47.63411159.383 125 52.1533194.580727 -46.32111168.721 125 52.1534174.581166 -47.65611203.977 124 52.1534444.581397 -48.76811220.134 128 52.1537094.583313 -47.86711354.520 131 52.1541194.586252 -47.76611560.832 130 52.1543514.587881 -46.38911675.203 125 52.1544464.588496 -45.55411718.564 120 52.1547654.588533 -47.87011753.168 120 52.1558034.588199 -47.64911871.195 125 52.1568104.587891 -47.76211985.109 130 52.1578584.587465 -47.89012105.388 131 52.1580834.587370 -47.95312131.218 136 52.1590714.586964 -47.63812244.801 135 52.1606254.586318 -46.20112423.354 134 52.1621644.585654 -45.56312600.551 135 52.1633024.585162 -45.74912731.604 134 52.1645234.584650 -45.25412871.901 136 52.1655344.584233 -44.28712987.870 131 52.1657724.584130 -43.62413015.224 129 52.1661124.584152 -45.11013053.855 124 52.1663474.584061 -47.83413080.844 121 52.1665054.583965 -45.92113099.423 121 52.1669554.583718 -42.71513155.394 120 52.1671364.584216 -43.40213195.809 121 52.1673934.585245 -43.56413271.835 126 52.1677334.585843 -43.19413328.191 130 52.1678814.586483 -44.14113375.556 135 52.1680834.587822 -44.22813470.075 139 52.1682044.589525 -43.77613588.248 132 52.1683724.590369 -42.76313648.878 130 52.1682384.590915 -43.24513694.887 129 52.1676634.591241 -43.57513762.995 131 52.1674904.591499 -42.28313789.280 133 52.1673874.591861 -43.09213816.646 133 52.1671614.592434 -43.68713863.891 133 52.1669154.593138 -43.41913919.670 131 52.1668514.594976 -42.25814047.076 134 52.1673514.598100 -41.95514268.001 133 52.1679184.600322 -42.07514434.267 133 52.1680694.601280 -42.01014502.413 133 52.1677674.602921 -40.58814620.167 132 52.1676664.603208 -39.78714642.685 130 52.1672214.603859 -40.52614709.779 135 52.1669314.604036 -39.77914744.140 141 52.1652044.604602 -39.54814940.486 139 52.1633434.605119 -38.39415150.557 144 52.1628144.605270 -38.53415210.343 143 52.1626104.605417 -39.79015235.368 143 52.1624334.606258 -40.67315298.759 144 52.1624494.606411 -41.86015309.126 145 52.1625284.606883 -43.51715342.560 146 52.1629184.609406 -42.38415520.937 143 52.1631174.610831 -42.21815620.906 149 52.1633504.612483 -42.86015736.878 144 52.1637974.614876 -41.54115908.483 143 52.1642354.617614 -41.96616102.087 142 52.1645084.619554 -40.96416237.964 139 52.1648964.621896 -40.51816404.387 140 52.1650614.623660 -39.44516526.797 142 52.1654354.626307 -39.89516713.072 141 52.1658614.629005 -40.10816903.646 141 52.1660304.629609 -39.52216949.201 143 52.1661984.630673 -39.57817024.523 144 52.1661854.631230 -39.91517062.930 143 52.1663914.632871 -39.08917178.076 143 52.1667384.635261 -36.78617346.430 143 52.1667794.635702 -36.06617376.756 143 52.1666194.636338 -36.05917426.408 141 52.1665994.636447 -35.99717434.186 142 52.1665194.637383 -34.35717498.943 147 52.1665184.637730 -33.91817522.617 147 52.1667474.639066 -30.63117617.783 152 52.1670104.640196 -32.87317700.602 149 52.1672154.640858 -33.89217751.395 146 52.1674964.641590 -37.78817813.033 143 52.1675864.642493 -38.35817875.652 138 52.1676684.643982 -38.70817978.230 137 52.1676784.644826 -38.38318036.039 142 52.1677294.647350 -38.37818208.799 146 52.1677814.650032 -37.13718392.488 144 52.1678304.652737 -37.62918577.602 142 52.1678944.655685 -36.91318779.328 141 52.1679524.658415 -37.24818966.369 141 52.1680014.661111 -36.82419150.881 143 52.1680674.664077 -35.99919353.980 142 52.1681604.666115 -35.33719493.820 140 52.1681564.666346 -35.14919509.535 140 52.1682504.666803 -34.83619543.484 136 -38.82719633.311 130 52.1680784.669408 -34.91019723.307 135 52.1680904.670219 -34.91719778.826 140 52.1681064.671060 -34.68219836.451 145 52.1681354.672517 -34.85419936.193 150 52.1682114.675636 -35.25120149.773 150 52.1682934.679149 -34.32820390.375 147 52.1683824.682223 -34.34320601.066 144 52.1684744.685515 -33.99020826.543 143 52.1685004.686094 -33.88820866.293 143 52.1688834.686988 -33.72420943.541 145 52.1707044.688346 -33.69521166.586 143 52.1723314.688941 -33.59721352.197 145 52.1739994.689558 -33.87521542.545 147 52.1750654.689953 -33.36921663.971 147 52.1753374.690134 -31.84321696.615 146 52.1754654.690517 -31.77221727.096 144 52.1753824.690927 -31.85221758.887 143 52.1746314.692364 -32.92521888.373 146 52.1734204.694546 -32.35922089.439 148 52.1725964.696052 -32.76822227.324 149 52.1722694.696651 -32.26922281.963 150 52.1720844.697531 -32.05622346.291 151 52.1717174.698148 -34.00122407.023 149 52.1713664.698379 -32.17222448.988 146 52.1711264.698693 -29.24522483.410 146 52.1710504.698832 -28.19822496.158 147 52.1709394.699056 -29.30322515.873 150 52.1708744.699217 -28.43022529.059 151 52.1708564.699615 -26.85922556.920 153 52.1708914.699828 -27.50222572.062 153 52.1707924.700253 -28.59822604.365 153 52.1703384.701001 -28.57522676.238 150 52.1701144.701345 -26.60222711.297 147 52.1700724.701458 -27.38622720.463 147 52.1693714.702647 -28.37522833.625 148 52.1692264.702880 -26.47622855.768 146 52.1691614.702976 -27.26522865.613 143 52.1689904.703266 -27.19022893.678 143 52.1687314.703701 -29.38422935.355 148 52.1683954.704272 -28.82622989.453 154 52.1681734.704659 -28.06223025.621 161 52.1678934.705142 -28.95323070.666 156 52.1671514.706455 -26.21423192.529 152 52.1670314.706663 -26.30323212.080 149 52.1668234.707043 -27.22423247.236 153 52.1662964.708034 -27.81423337.029 148 52.1656174.709496 -23.77823463.387 150 52.1655714.709668 -23.05323476.221 150 52.1654004.710301 -25.11823523.707 149 52.1653544.710969 -26.20023572.977 149 52.1653634.711101 -27.48923582.100 148 52.1653654.712149 -27.84323653.719 142 52.1654024.713994 -28.35223780.170 138 52.1654164.714601 -25.73423821.750 139 52.1655144.716644 -26.96323962.391 136 52.1656354.717630 -26.59724031.080 134 52.1657674.718210 -27.61624073.725 134 52.1659124.719448 -28.76424159.863 139 52.1659484.719797 -26.95624184.035 141 52.1660794.720983 -27.96724266.566 136 52.1661714.721854 -27.69524327.023 141 52.1664284.724416 -27.88124504.434 140 52.1665304.725416 -27.63424574.006 135 52.1666734.726996 -27.38124683.375 133 52.1667514.728093 -25.78924758.785 138 52.1669684.729780 -26.81524877.486 138 52.1670794.730191 -28.36424908.320 137 52.1675754.732232 -28.17825058.414 132 52.1682774.735112 -27.82325270.270 131 52.1690824.738405 -27.98525512.873 130 52.1699464.741952 -28.11125773.865 128 52.1706544.744844 -27.94525986.834 129 52.1713524.747726 -27.01026198.627 130 52.1721314.750941 -27.28626435.133 132 52.1728444.753843 -27.21926648.963 133 52.1736814.757295 -27.41726902.324 129 52.1743754.760138 -26.80127112.082 123 52.1746184.761152 -28.46327186.539 128 52.1751004.763142 -26.69627332.912 133 52.1758304.766142 -26.36727553.619 131 52.1762054.767669 -26.80327665.994 125 52.1763184.768125 -26.30927699.516 120 52.1765984.769235 -25.30527781.459 116 52.1766354.769870 -26.35527825.582 121 52.1766754.769962 -26.26727833.430 127 52.1768604.770378 -27.57627868.967 132 52.1775164.773066 -25.76428066.828 134 52.1777464.773980 -25.78728134.258 141 52.1777694.774080 -25.01228141.082 140 52.1777314.774263 -25.73828157.266 135 52.1778224.774853 -26.60428199.160 135 52.1780604.775840 -28.06228271.844 141 52.1786974.778355 -26.53928457.740 141 52.1790424.779603 -26.89528551.531 141 52.1794784.781279 -26.38728676.139 146 52.1800164.782840 -22.95928798.699 146 52.1800984.783031 -22.26628814.516 146 52.1803774.784271 -23.14628905.996 149 52.1804764.785079 -25.58228962.525 147 52.1810354.787386 -25.89729132.123 142 52.1815904.789653 -24.75629299.084 137 52.1822944.792493 -24.71729508.518 135 52.1830134.795474 -24.29629727.562 131 52.1837374.798352 -24.50829940.074 129 52.1845214.801539 -24.36730174.928 130 52.1852594.804547 -24.18830396.639 127 52.1855454.805833 -24.18630490.260 127 52.1854484.806882 -24.10630564.219 126 52.1850334.807486 -25.47830627.148 126 52.1834604.808431 -24.90330813.635 131 52.1822074.809195 -24.58030962.584 136 52.1810834.809891 -24.35931096.412 141 52.1795224.810867 -24.42831282.193 142 52.1778994.811876 -24.42331475.873 143 52.1763364.812852 -24.34931662.193 146 52.1754384.813403 -22.58731768.980 144 52.1752114.813694 -23.09631801.385 144 52.1743164.815482 -21.08931959.053 140 52.1736544.816823 -21.75432076.576 135 52.1725994.818952 -22.01232263.688 135 52.1716634.820852 -20.96032430.266 134 52.1711644.821879 -19.41732519.783 139 52.1703034.823609 -19.61832672.074 144 52.1698604.824575 -20.27732754.559 146 52.1697644.825065 -20.45332789.973 146 52.1696954.826171 -19.75232865.957 141 52.1695894.828107 -19.76832999.035 136 52.1693254.828612 -19.61933048.465 135 52.1679204.829028 -19.80833207.402 136 52.1665304.829448 -19.67633364.809 134 52.1654104.829765 -18.99033491.359 136 52.1648074.829945 -18.97733559.566 135 52.1646244.829696 -19.90033589.695 133 52.1645344.828449 -20.70333675.617 139 52.1643734.826317 -19.51333822.594 138 52.1642084.824265 -19.24033964.398 139 52.1639914.821497 -19.09234155.410 139 52.1636454.819694 -19.09134285.281 144 52.1635654.819382 -19.07934308.328 139 52.1632324.817972 -18.20134411.719 134 52.1627444.816026 -18.70734555.699 133 52.1622614.814001 -20.34434704.340 137 52.1618874.812426 -18.68234819.980 142 52.1611164.809564 -19.42235033.805 142 52.1605764.807727 -18.32435173.180 146 52.1605134.807295 -17.96935203.625 146 52.1601684.806108 -18.40935293.367 151 52.1599454.805334 -19.72935351.980 144 52.1597264.804578 -20.61935409.250 144 52.1591034.802395 -19.08435573.930 144 52.1583464.799758 -18.88935772.879 148 52.1580934.798748 -17.82835847.656 154 52.1578234.797875 -19.13035914.516 149 52.1576674.797335 -18.21535955.367 148 52.1570264.795065 -18.62136126.309 153 52.1566554.793787 -17.62736223.078 152 52.1565754.793592 -18.34036239.043 152 52.1561644.792064 -17.80336353.352 154 52.1556224.790229 -17.73536492.688 154 52.1551734.788858 -17.41136598.969 158 52.1548324.787813 -17.27236679.973 162 52.1546674.787433 -17.53936711.918 156 52.1545244.787028 -16.70036743.848 161 52.1538094.785525 -16.51936874.840 156 52.1535764.785063 -16.97736914.938 161 52.1534404.784798 -16.14436939.215 156 52.1532354.784390 -15.84736975.016 154 52.1531654.784164 -14.99736991.930 153 52.1529584.784003 -14.83137018.910 153 52.1527594.783773 -16.10837046.738 149 52.1521374.782561 -15.66537154.730 153 52.1511294.780609 -16.02937329.328 152 52.1503624.779400 -14.62037448.773 154 52.1496814.778564 -14.64937543.875 153 52.1496154.778510 -14.21337552.223 153 52.1486054.777459 -14.98737685.676 156 52.1472324.775999 -15.64737868.199 153 52.1465254.775243 -14.64037962.297 158 52.1464214.775118 -13.98637976.523 165 52.1463754.775061 -14.06937982.793 164 52.1462934.774954 -14.07137994.422 163 52.1456144.774324 -14.89138082.004 160 52.1448754.773533 -14.44738180.465 155 52.1434984.772117 -14.06038361.789 151 52.1422194.770757 -14.38638531.668 150 52.1409444.769346 -14.99338703.527 151 52.1397854.768219 -14.64738854.145 152 52.1388144.767651 -13.76138968.977 151 52.1383874.767451 -14.16139018.578 155 52.1378204.767188 -14.07539084.055 150 52.1364894.766504 -13.91639239.504 149 52.1351484.765840 -14.15739395.539 148 52.1342274.765132 -14.09739509.148 147 52.1332464.763986 -13.22739644.289 146 52.1331494.763808 -13.33039660.273 152 52.1330994.763722 -13.11939668.605 152 52.1325794.762756 -13.61239756.480 151 52.1323144.762186 -14.42739805.504 150 52.1319064.761242 -14.15239884.336 155 52.1317174.760770 -14.34939922.867 150 52.1314694.760137 -13.08839974.125 150 52.1315944.759827 -11.90240010.727 151 52.1327904.759651 -11.43540144.621 150 52.1338054.759484 -15.17740258.297 145 52.1349084.759225 -15.58240382.500 142 52.1361724.758527 -16.57840531.109 140 52.1375704.757653 -17.35340697.695 140 52.1390114.756740 -16.39240869.914 141 52.1405344.755801 -16.28941051.125 142 52.1419534.754894 -16.77841220.805 143 52.1434214.753995 -16.36941394.902 142 52.1445874.753272 -16.75041534.156 146 52.1452524.752847 -16.26041613.688 146 52.1457924.752519 -15.24741677.918 147 52.1470064.751747 -15.33241822.641 142 52.1479444.751171 -15.73141934.527 140 52.1494184.750245 -15.68042110.406 138 52.1505104.749562 -15.59242240.582 138 52.1519834.748641 -15.26942416.219 139 52.1534024.747750 -15.10342585.520 138 52.1550054.746752 -15.16042776.492 134 52.1564754.745838 -14.84242951.605 135 52.1578494.744985 -14.77743115.223 136 52.1593344.744057 -14.04443292.215 135 52.1607294.743177 -13.99043458.855 132 52.1619984.742389 -14.63943609.961 134 52.1633864.741532 -14.41743775.137 133 52.1649784.740549 -14.86043964.496 135 52.1662664.739749 -14.78744118.012 138 52.1674334.739013 -14.94244257.285 140 52.1686594.738245 -13.64844403.371 139 52.1688714.738113 -13.53644428.473 137 52.1689644.737550 -15.28644473.926 137 52.1688104.736928 -16.05744519.777 142 52.1683284.735026 -14.44944660.559 143 52.1679754.733634 -13.91544763.574 144 52.1677244.732635 -14.29744837.477 150 52.1672614.730732 -13.53744977.227 150 52.1669074.729329 -9.84845081.336 152 52.1668794.729179 -9.87745091.957 152 52.1668064.728150 -10.22445163.047 157 52.1667654.727596 -10.08145201.297 152 52.1667524.727484 -10.01745209.062 151 52.1666904.726923 -12.63345248.082 151 52.1665494.725541 -11.78845343.965 146 52.1663134.723182 -12.59645507.195 142 52.1661264.721340 -12.27445635.223 146 52.1660594.720700 -12.71445679.641 151 52.1658314.718732 -11.61045816.660 150 52.1657444.718019 -10.82245866.195 147 52.1655844.717064 -10.46445934.109 147 52.1654494.715591 -10.26846036.113 147 52.1653974.712938 -9.64546217.883 144 52.1653944.711074 -8.86946345.418 139 52.1653944.710975 -8.80646352.121 139 52.1654944.710481 -7.57646388.367 138 52.1656994.709470 -6.17846462.273 144 52.1658214.709060 -8.13646493.805 145 52.1658944.708841 -8.45246510.770 145 52.1660804.708453 -8.26546544.617 145 52.1666934.707448 -9.21546642.031 140 52.1668114.707152 -9.11046666.172 139 52.1678134.705366 -9.30746831.586 137 52.1686534.703917 -7.65046967.898 138 52.1689184.703430 -8.14447012.555 138 52.1696704.702231 -8.13047129.766 137 52.1700224.701613 -6.76847187.320 132 52.1700984.701475 -6.94347199.922 131 52.1701734.701242 -7.55047217.930 131 52.1705004.700774 -7.49947266.938 132 52.1711544.699627 -7.00347374.004 130 52.1711744.699114 -6.42047412.094 129 52.1711504.698662 -7.19347446.348 126 52.1712374.698524 -8.50847460.074 126 52.1714794.698283 -11.14147491.945 125 52.1716854.698124 -9.89547517.422 122 52.1718184.698028 -10.58247533.586 121 52.1721034.697448 -11.44347585.840 121 52.1722144.696826 -11.17047630.078 119 52.1723614.696462 -9.84847660.348 121 52.1726434.695954 -10.42447707.227 126 52.1729704.695345 -10.37247762.523 131 52.1735344.694340 -9.38447855.578 132 52.1745094.692559 -9.20948018.758 135 52.1753104.691099 -9.49248152.527 136 52.1753754.690954 -8.57948164.477 134 52.1754914.690600 -9.26848191.867 126 52.1754714.690450 -9.69748202.711 126 52.1754354.690280 -10.17348215.047 126 52.1752634.690001 -10.85048243.352 128 52.1750064.689857 -10.71148273.598 135 52.1738394.689427 -10.23948406.762 135 52.1725374.688952 -10.61248555.211 138 52.1712484.688466 -9.78548702.414 138 52.1701844.687925 -10.44848826.664 139 52.1692634.687174 -10.34348941.523 141 52.1687964.686788 -10.08548999.695 142 52.1686194.686477 -11.18149029.137 143 52.1685544.686284 -10.94749043.965 143 52.1684854.685858 -10.95349074.570 144 52.1684554.683848 -11.67649212.082 144 52.1683944.681897 -11.03749345.746 144 52.1683584.680961 -11.05749410.043 149 52.1683094.678411 -10.81549584.641 148 52.1682624.676073 -10.30749744.703 149 52.1682094.673757 -10.05249903.262 150 52.1681744.671863 -10.41350032.922 146 52.1681334.669941 -9.72150164.574 146 52.1681684.668236 -12.43750281.664 140 52.1681854.668010 -13.18850297.246 138 52.1682684.667075 -10.17450361.938 137 52.1683014.666693 -9.15050387.996 136 52.1682004.666227 -9.42150422.098 135 52.1681444.664729 -9.66550525.109 137 52.1681334.664196 -10.44450561.645 143 52.1681314.664090 -10.35750568.887 148 52.1681294.663985 -10.34550576.023 148 52.1681274.663881 -10.70250583.141 153 52.1681244.663669 -10.91950597.660 147 52.1681114.663256 -10.34950625.930 142 52.1680694.661579 -10.93650740.809 143 52.1680314.659893 -10.87050856.312 145 52.1680084.658832 -9.22550928.723 146 52.1680024.658398 -11.43850958.660 147 52.1679694.656830 -10.42751066.004 150 52.1679644.656617 -10.25951080.574 155 52.1679624.656404 -10.14551095.156 158 52.1679494.655777 -9.75051138.074 153 52.1679284.654434 -8.39451230.012 151 52.1679184.654113 -9.76451251.961 150 52.1679014.652892 -8.42651335.562 147 52.1678994.652683 -9.83651349.844 145 52.1678934.652477 -11.11951363.926 146 52.1678594.651140 -8.17851455.547 146 52.1678354.650061 -10.63351529.289 146 52.1678214.649544 -9.18151564.785 151 52.1677834.647847 -9.33851681.039 146 52.1677694.647095 -9.71551732.449 145 52.1677174.645078 -9.20051870.602 143 52.1677034.644353 -8.38651920.242 148 52.1676874.643302 -7.98551992.176 143 52.1675374.641851 -6.79152093.016 141 52.1675304.641647 -6.75752106.988 141 52.1675114.641454 -6.02152120.336 141 52.1673404.641039 -4.64452155.238 143 52.1672664.640960 -3.37452165.152 143 52.1672164.640776 -2.22152179.098 143 52.1671644.640503 -2.39452198.395 144 52.1670894.640321 -2.59852213.508 145 52.1668544.639403 0.03352281.957 150 52.1665454.637842 -2.49152394.453 150 52.1665264.637643 -2.26552408.152 150 52.1665374.636962 -3.91252454.809 149 52.1665644.636682 -3.69652474.293 144 52.1666104.636312 -3.89652500.039 141 52.1667144.636055 -3.18852521.621 134 52.1667634.636008 -2.83152527.668 133 52.1667484.635376 -5.46652572.562 131 52.1667034.634648 -6.95552623.336 130 52.1664584.633182 -7.76952726.801 133 52.1661944.631494 -7.46752845.898 138 52.1661784.631389 -6.30152853.359 138 52.1662044.630957 -6.57352883.324 139 52.1661914.630406 -7.25252921.246 139 52.1660634.629666 -7.08252973.891 141 52.1657474.628169 -6.16953082.691 143 52.1653764.625756 -7.41253252.848 144 52.1652354.624823 -7.15353318.578 149 52.1652024.624623 -7.61353332.441 152 52.1651274.624123 -7.37953368.000 147 52.1650794.623208 -5.78653431.348 146 52.1648734.621501 -5.96353551.410 146 52.1645524.619637 -6.88053683.918 146 52.1643074.617829 -6.42853810.645 144 52.1639744.615813 -7.30553953.344 143 52.1636144.613915 -7.22554089.320 143 52.1633744.612401 -7.02954196.559 144 52.1631194.610572 -6.36354324.961 146 52.1630414.609943 -6.87454368.922 152 52.1627844.608106 -7.01854497.836 149 52.1624744.606300 -4.25254626.086 147 52.1624644.606209 -3.45254632.281 147 52.1626934.605341 -1.20754700.566 149 52.1628724.605228 -1.03854721.930 151 52.1641794.604879 -0.20954869.668 152 52.1646714.604741 -1.36454925.262 147 52.1652954.604545 -1.46854995.914 142 52.1659704.604329 -1.19355072.480 137 52.1671604.603877 -1.77055208.625 134 52.1677624.602975 -2.85455300.949 131 52.1680974.601476 -4.11255410.355 132 52.1681134.601360 -4.14955418.312 132 52.1680334.600456 -4.15255481.445 132 52.1676234.599321 -5.49855571.699 134 52.1672864.597440 -4.41955705.805 136 52.1670454.596002 -5.19055807.746 141 52.1668274.594154 -4.08955937.043 142 52.1670804.592576 -3.95556049.645 144 52.1672624.592250 -3.36556079.938 144 52.1674464.591664 -4.12556125.316 144 52.1676894.591176 -3.01556169.434 145 52.1682554.590843 -2.72056236.492 142 52.1683834.590294 -4.40356282.828 142 52.1683544.590071 -4.57356298.406 142 52.1680964.588632 -4.14056400.805 138 52.1680964.588029 -4.29356442.297 140 52.1679264.586518 -3.73056548.066 139 52.1678404.585985 -3.05656584.906 138 52.1677414.585739 -2.18856605.207 137 52.1675434.585483 -3.21756633.926 138 52.1674544.585338 -3.91456648.379 139 52.1671444.584146 -3.93856736.969 140 52.1670934.583967 -4.40656750.172 140 52.1669334.583704 -2.91756776.293 139 52.1667104.583694 -1.74856801.344 139 52.1664794.583783 -1.43656827.758 139 52.1662124.583600 -3.66456861.805 139 52.1654934.582017 -4.62256996.648 140 52.1649304.579679 -3.62557168.555 144 52.1643804.578469 -2.75657272.457 146 52.1635014.577662 -3.01757385.652 145 52.1625974.577250 -3.05457490.188 148 52.1622414.577039 -2.68757532.340 149 52.1620694.576578 -0.93357570.473 150 52.1620304.576359 -1.17457586.070 147 52.1619074.576094 -2.56657609.926 146 52.1613564.575544 -2.22757682.035 143 52.1612124.575361 -2.74857702.391 143 52.1610374.574944 -3.57157737.094 143 52.1607274.573186 -3.74257862.391 144 52.1603694.570929 -3.24458021.988 148 52.1600604.568861 -2.67358167.594 148 52.1598194.567155 -3.00358287.543 147 52.1598244.565118 -1.79558427.641 146 52.1602074.563809 1.03858527.375 148 52.1603514.563512 1.81158553.391 148 52.1611554.562450 2.94358668.957 144 52.1619934.561484 1.80658783.414 141 52.1622194.561338 3.38258810.738 137 52.1624674.561092 2.88358843.598 135 52.1625774.560891 4.24458861.824 135 52.1627644.560561 3.10558894.566 135 52.1627614.560113 4.60758927.809 131 52.1630314.559535 5.10358977.656 136 52.1633484.558918 6.01659032.664 141 52.1640344.557538 5.04759154.078 140 52.1641144.557394 3.77359167.371 140 52.1643874.557286 4.96859200.039 137 52.1646664.557503 4.10859235.332 133 52.1648634.557817 3.53159266.086 126 52.1649524.558125 2.91759289.641 121 52.1648534.558425 1.38359313.840 116 52.1645704.558410 -0.40859346.387 114 52.1642414.558131 -1.41459387.551 116 52.1638794.557653 -3.25859439.488 116 52.1638394.557555 -2.98759447.766 116 52.1636964.557110 -2.47959482.141 118 52.1636764.556646 -2.92759514.020 118 52.1638674.556160 -1.73359556.781 118 52.1639294.555844 -1.99559583.906 117 52.1637644.555376 -3.78859621.531 122 52.1636964.555200 -3.38259635.824 126 52.1634274.554491 -2.74559692.816 131 52.1630794.553505 -1.90659770.613 136 52.1628404.552601 -1.91759838.047 141 52.1627094.551952 -0.81059884.504 144 52.1625674.551680 0.36759909.348 146 52.1623224.550890 -1.59659971.922 140 52.1622224.550647 -1.58359991.848 140 52.1620034.550265 -1.18960026.914 134 52.1619684.550206 -1.41160032.520 132 52.1619644.549822 -1.31360062.523 126 -1.60260073.609 124 52.1620084.549397 -1.18760097.016 123 52.1614714.548632 -0.81560176.594 127 52.1614004.548491 -0.41860188.969 127 52.1609544.548382 1.32360243.086 132 52.1605564.548139 1.47260292.590 135 52.1600504.546935 -0.01660392.871 136 52.1593874.544874 -0.92160551.926 134 52.1588774.543199 -0.55660679.871 133 52.1586604.542371 -0.76160741.395 140 52.1585914.542126 -0.53660759.719 135 52.1585704.542049 -0.96960765.504 134 52.1584884.541638 -0.88260795.719 132 52.1580674.540529 -1.72460885.262 138 52.1576744.539322 -1.25860978.625 138 52.1576204.538925 -0.19861006.480 138 52.1574864.538467 -0.24861041.398 139 52.1572464.537961 -1.61161085.227 139 52.1571504.537592 -1.11461112.738 139 52.1569474.536576 0.55761185.070 136 52.1569554.536391 1.40761197.637 131 52.1569544.536336 1.70761201.207 128 52.1568814.536036 0.24661221.133 127 52.1568614.535962 0.10461227.023 127 52.1567614.535730 0.08461246.539 127 52.1565864.535160 -0.29361290.488 132 52.1563504.534295 0.16761355.270 137 52.1559484.532809 -0.14161466.238 138 52.1558994.532643 -0.34961478.734 139 52.1558604.532489 0.62661489.664 138 52.1559174.532014 0.09261525.488 138 52.1558934.531939 -0.82261531.219 139 52.1552554.530295 0.78861664.508 140 52.1546904.528888 -0.02661779.469 141 52.1541464.527516 0.71361891.168 141 52.1537274.526355 0.82261982.797 137 52.1535914.525837 1.59862021.266 137 52.1534924.525575 0.99762043.000 137 52.1531834.524980 0.88862096.383 144 52.1530654.524692 1.15862120.027 139 52.1528494.524092 0.72662167.488 136 52.1525864.523569 1.73062215.418 134 52.1525594.523520 1.44462219.906 134 52.1522654.522428 3.63062302.027 137 52.1521794.521409 3.33262373.066 132 52.1524364.521165 3.36862413.348 131 52.1525324.521482 2.26162438.418 130 52.1525494.521673 2.40262451.051 130 52.1527654.521830 2.02962483.516 128 52.1538134.521397 1.17062604.395 125 52.1544264.520308 0.95262706.305 121 52.1549624.518958 0.99062816.590 123 52.1551894.517759 0.91562902.438 125 52.1553264.516648 2.71862979.586 124 52.1553594.516311 2.93763003.293 121 52.1553664.516065 2.08763020.230 122 52.1554514.514753 0.98663110.492 121 52.1552194.514222 1.22763161.836 119 52.1550804.514166 1.25163177.695 119 52.1548864.514089 1.45863200.371 121 52.1547224.514053 1.49963218.465 123 52.1545504.514269 3.41963250.922 121 52.1545664.514301 3.77263253.266 121 52.1546444.514393 4.26563263.984 121 52.1547474.514282 4.10263279.840 118 52.1547924.513436 4.00563338.270 122 52.1547454.513320 4.57763347.727 123 52.1546334.513111 2.97563369.039 122 52.1538574.512928 1.88463456.891 124 52.1531464.512836 1.48363536.145 126 52.1521734.512388 2.14963649.129 129 52.1516714.512063 2.15963709.297 130 52.1512604.511665 2.01763762.699 130 52.1503624.510933 2.13963875.027 128 52.1502144.510778 1.89563894.504 127 52.1500104.510465 2.45563926.457 127 52.1496684.510018 3.15963974.539 122 52.1496124.509778 3.36463995.266 117 52.1497064.509592 2.58064011.211 117 52.1500524.508905 2.06464072.137 118 52.1502214.508525 1.46464104.246 118 52.1502034.508107 1.03664134.801 119 52.1499554.507584 1.04264179.988 126 52.1493634.506292 0.89964290.266 127 52.1489124.505152 1.49864382.977 129 52.1486424.504251 1.60764451.074 130 52.1486554.504016 2.33064469.062 130 52.1492444.503194 2.08064555.297 126 52.1495914.502560 2.08664614.906 128 52.1495424.502316 2.44364632.984 129 52.1493174.501910 2.80364670.188 127 EDGE7053420061559625 2500 EDGE705 2500 Release EN006-B0625-00 stravalib-1.3.0/stravalib/tests/resources/strava_swagger.json000066400000000000000000003103661442076457100246020ustar00rootroot00000000000000{ "swagger": "2.0", "info": { "title": "Strava API v3", "description": "The [Swagger Playground](https://developers.strava.com/playground) is the easiest way to familiarize yourself with the Strava API by submitting HTTP requests and observing the responses before you write any client code. It will show what a response will look like with different endpoints depending on the authorization scope you receive from your athletes. To use the Playground, go to https://www.strava.com/settings/api and change your “Authorization Callback Domain†to developers.strava.com. Please note, we only support Swagger 2.0. There is a known issue where you can only select one scope at a time. For more information, please check the section “client code†at https://developers.strava.com/docs.", "version": "3.0.0" }, "host": "www.strava.com", "schemes": [ "https" ], "basePath": "/api/v3", "produces": [ "application/json" ], "securityDefinitions": { "strava_oauth": { "type": "oauth2", "flow": "accessCode", "authorizationUrl": "https://www.strava.com/api/v3/oauth/authorize", "tokenUrl": "https://www.strava.com/api/v3/oauth/token", "scopes": { "read": "Read public segments, public routes, public profile data, public posts, public events, club feeds, and leaderboards", "read_all": "Read private routes, private segments, and private events for the user", "profile:read_all": "Read all profile information even if the user has set their profile visibility to Followers or Only You", "profile:write": "Update the user's weight and Functional Threshold Power (FTP), and access to star or unstar segments on their behalf", "activity:read": "Read the user's activity data for activities that are visible to Everyone and Followers, excluding privacy zone data", "activity:read_all": "The same access as activity:read, plus privacy zone data and access to read the user's activities with visibility set to Only You", "activity:write": "Access to create manual activities and uploads, and access to edit any activities that are visible to the app, based on activity read access level" } } }, "security": [ { "strava_oauth": [ "public" ] } ], "parameters": { "page": { "name": "page", "in": "query", "description": "Page number. Defaults to 1.", "type": "integer" }, "perPage": { "name": "per_page", "in": "query", "description": "Number of items per page. Defaults to 30.", "type": "integer", "default": 30 } }, "paths": { "/athletes/{id}/stats": { "get": { "operationId": "getStats", "summary": "Get Athlete Stats", "description": "Returns the activity stats of an athlete. Only includes data from activities set to Everyone visibilty.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the athlete. Must match the authenticated athlete.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Athletes" ], "responses": { "200": { "description": "Activity stats of the athlete.", "schema": { "$ref": "https://developers.strava.com/swagger/activity_stats.json#/ActivityStats" } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/athlete": { "get": { "operationId": "getLoggedInAthlete", "summary": "Get Authenticated Athlete", "description": "Returns the currently authenticated athlete. Tokens with profile:read_all scope will receive a detailed athlete representation; all others will receive a summary representation.", "tags": [ "Athletes" ], "responses": { "200": { "description": "Profile information for the authenticated athlete.", "schema": { "$ref": "https://developers.strava.com/swagger/athlete.json#/DetailedAthlete" }, "examples": { "application/json": { "id": 1234567890987654321, "username": "marianne_t", "resource_state": 3, "firstname": "Marianne", "lastname": "Teutenberg", "city": "San Francisco", "state": "CA", "country": "US", "sex": "F", "premium": true, "created_at": "2017-11-14T02:30:05Z", "updated_at": "2018-02-06T19:32:20Z", "badge_type_id": 4, "profile_medium": "https://xxxxxx.cloudfront.net/pictures/athletes/123456789/123456789/2/medium.jpg", "profile": "https://xxxxx.cloudfront.net/pictures/athletes/123456789/123456789/2/large.jpg", "friend": null, "follower": null, "follower_count": 5, "friend_count": 5, "mutual_friend_count": 0, "athlete_type": 1, "date_preference": "%m/%d/%Y", "measurement_preference": "feet", "clubs": [], "ftp": null, "weight": 0, "bikes": [ { "id": "b12345678987655", "primary": true, "name": "EMC", "resource_state": 2, "distance": 0 } ], "shoes": [ { "id": "g12345678987655", "primary": true, "name": "adidas", "resource_state": 2, "distance": 4904 } ] } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } }, "put": { "operationId": "updateLoggedInAthlete", "summary": "Update Athlete", "description": "Update the currently authenticated athlete. Requires profile:write scope.", "consumes": [ "multipart/form-data" ], "tags": [ "Athletes" ], "parameters": [ { "name": "weight", "in": "path", "description": "The weight of the athlete in kilograms.", "required": true, "type": "number", "format": "float" } ], "responses": { "200": { "description": "Profile information for the authenticated athlete.", "schema": { "$ref": "https://developers.strava.com/swagger/athlete.json#/DetailedAthlete" }, "examples": { "application/json": { "id": 12345678987655098765444, "username": "marianne_v", "resource_state": 3, "firstname": "Marianne", "lastname": "V.", "city": "San Francisco", "state": "CA", "country": "US", "sex": "F", "premium": true, "created_at": "2017-11-14T02:30:05Z", "updated_at": "2018-02-06T19:32:20Z", "badge_type_id": 4, "profile_medium": "https://xxxxxx.cloudfront.net/pictures/athletes/1234567898765509876/1234567898765509876/2/medium.jpg", "profile": "https://xxxxx.cloudfront.net/pictures/athletes/1234567898765509876/1234567898765509876/2/large.jpg", "friend": null, "follower": null, "follower_count": 5, "friend_count": 5, "mutual_friend_count": 0, "athlete_type": 1, "date_preference": "%m/%d/%Y", "measurement_preference": "feet", "clubs": [], "ftp": null, "weight": 0, "bikes": [ { "id": "b1234567898765509876", "primary": true, "name": "EMC", "resource_state": 2, "distance": 0 } ], "shoes": [ { "id": "g1234567898765509876", "primary": true, "name": "adidas", "resource_state": 2, "distance": 4904 } ] } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/athlete/zones": { "get": { "operationId": "getLoggedInAthleteZones", "summary": "Get Zones", "description": "Returns the the authenticated athlete's heart rate and power zones. Requires profile:read_all.", "tags": [ "Athletes" ], "responses": { "200": { "description": "Heart rate and power zones.", "schema": { "$ref": "https://developers.strava.com/swagger/zones.json#/Zones" }, "examples": { "application/json": [ { "distribution_buckets": [ { "max": 0, "min": 0, "time": 1498 }, { "max": 50, "min": 0, "time": 62 }, { "max": 100, "min": 50, "time": 169 }, { "max": 150, "min": 100, "time": 536 }, { "max": 200, "min": 150, "time": 672 }, { "max": 250, "min": 200, "time": 821 }, { "max": 300, "min": 250, "time": 529 }, { "max": 350, "min": 300, "time": 251 }, { "max": 400, "min": 350, "time": 80 }, { "max": 450, "min": 400, "time": 81 }, { "max": -1, "min": 450, "time": 343 } ], "type": "power", "resource_state": 3, "sensor_based": true } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segments/{id}": { "get": { "operationId": "getSegmentById", "summary": "Get Segment", "description": "Returns the specified segment. read_all scope required in order to retrieve athlete-specific segment information, or to retrieve private segments.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the segment.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Segments" ], "responses": { "200": { "description": "Representation of a segment.", "schema": { "$ref": "https://developers.strava.com/swagger/segment.json#/DetailedSegment" }, "examples": { "application/json": { "id": 229781, "resource_state": 3, "name": "Hawk Hill", "activity_type": "Ride", "distance": 2684.82, "average_grade": 5.7, "maximum_grade": 14.2, "elevation_high": 245.3, "elevation_low": 92.4, "start_latlng": [ 37.8331119, -122.4834356 ], "end_latlng": [ 37.8280722, -122.4981393 ], "climb_category": 1, "city": "San Francisco", "state": "CA", "country": "United States", "private": false, "hazardous": false, "starred": false, "created_at": "2009-09-21T20:29:41Z", "updated_at": "2018-02-15T09:04:18Z", "total_elevation_gain": 155.733, "map": { "id": "s229781", "polyline": "}g|eFnpqjVl@En@Md@HbAd@d@^h@Xx@VbARjBDh@OPQf@w@d@k@XKXDFPH\\EbGT`AV`@v@|@NTNb@?XOb@cAxAWLuE@eAFMBoAv@eBt@q@b@}@tAeAt@i@dAC`AFZj@dB?~@[h@MbAVn@b@b@\\d@Eh@Qb@_@d@eB|@c@h@WfBK|AMpA?VF\\\\t@f@t@h@j@|@b@hCb@b@XTd@Bl@GtA?jAL`ALp@Tr@RXd@Rx@Pn@^Zh@Tx@Zf@`@FTCzDy@f@Yx@m@n@Op@VJr@", "resource_state": 3 }, "effort_count": 309974, "athlete_count": 30623, "star_count": 2428, "athlete_segment_stats": { "pr_elapsed_time": 553, "pr_date": "1993-04-03", "effort_count": 2 } } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segments/starred": { "get": { "operationId": "getLoggedInAthleteStarredSegments", "summary": "List Starred Segments", "description": "List of the authenticated athlete's starred segments. Private segments are filtered out unless requested by a token with read_all scope.", "parameters": [ { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Segments" ], "responses": { "200": { "description": "List of the authenticated athlete's starred segments.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/segment.json#/SummarySegment" } }, "examples": { "application/json": { "id": 229781, "resource_state": 3, "name": "Hawk Hill", "activity_type": "Ride", "distance": 2684.82, "average_grade": 5.7, "maximum_grade": 14.2, "elevation_high": 245.3, "elevation_low": 92.4, "start_latlng": [ 37.8331119, -122.4834356 ], "end_latlng": [ 37.8280722, -122.4981393 ], "climb_category": 1, "city": "San Francisco", "state": "CA", "country": "United States", "private": false, "hazardous": false, "starred": false, "created_at": "2009-09-21T20:29:41Z", "updated_at": "2018-02-15T09:04:18Z", "total_elevation_gain": 155.733, "map": { "id": "s229781", "polyline": "}g|eFnpqjVl@En@Md@HbAd@d@^h@Xx@VbARjBDh@OPQf@w@d@k@XKXDFPH\\EbGT`AV`@v@|@NTNb@?XOb@cAxAWLuE@eAFMBoAv@eBt@q@b@}@tAeAt@i@dAC`AFZj@dB?~@[h@MbAVn@b@b@\\d@Eh@Qb@_@d@eB|@c@h@WfBK|AMpA?VF\\\\t@f@t@h@j@|@b@hCb@b@XTd@Bl@GtA?jAL`ALp@Tr@RXd@Rx@Pn@^Zh@Tx@Zf@`@FTCzDy@f@Yx@m@n@Op@VJr@", "resource_state": 3 }, "effort_count": 309974, "athlete_count": 30623, "star_count": 2428, "athlete_segment_stats": { "pr_elapsed_time": 553, "pr_date": "1993-04-03", "effort_count": 2 } } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segments/{id}/starred": { "put": { "operationId": "starSegment", "summary": "Star Segment", "description": "Stars/Unstars the given segment for the authenticated athlete. Requires profile:write scope.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the segment to star.", "required": true, "type": "integer", "format": "int64" }, { "name": "starred", "in": "formData", "description": "If true, star the segment; if false, unstar the segment.", "type": "boolean", "required": true, "default": false } ], "tags": [ "Segments" ], "responses": { "200": { "description": "Representation of a segment.", "schema": { "$ref": "https://developers.strava.com/swagger/segment.json#/DetailedSegment" }, "examples": { "application/json": { "id": 229781, "resource_state": 3, "name": "Hawk Hill", "activity_type": "Ride", "distance": 2684.82, "average_grade": 5.7, "maximum_grade": 14.2, "elevation_high": 245.3, "elevation_low": 92.4, "start_latlng": [ 37.8331119, -122.4834356 ], "end_latlng": [ 37.8280722, -122.4981393 ], "climb_category": 1, "city": "San Francisco", "state": "CA", "country": "United States", "private": false, "hazardous": false, "starred": false, "created_at": "2009-09-21T20:29:41Z", "updated_at": "2018-02-15T09:04:18Z", "total_elevation_gain": 155.733, "map": { "id": "s229781", "polyline": "}g|eFnpqjVl@En@Md@HbAd@d@^h@Xx@VbARjBDh@OPQf@w@d@k@XKXDFPH\\EbGT`AV`@v@|@NTNb@?XOb@cAxAWLuE@eAFMBoAv@eBt@q@b@}@tAeAt@i@dAC`AFZj@dB?~@[h@MbAVn@b@b@\\d@Eh@Qb@_@d@eB|@c@h@WfBK|AMpA?VF\\\\t@f@t@h@j@|@b@hCb@b@XTd@Bl@GtA?jAL`ALp@Tr@RXd@Rx@Pn@^Zh@Tx@Zf@`@FTCzDy@f@Yx@m@n@Op@VJr@", "resource_state": 3 }, "effort_count": 309974, "athlete_count": 30623, "star_count": 2428, "athlete_segment_stats": { "pr_elapsed_time": 553, "pr_date": "1993-04-03", "effort_count": 2 } } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segment_efforts": { "get": { "operationId": "getEffortsBySegmentId", "summary": "List Segment Efforts", "description": "Returns a set of the authenticated athlete's segment efforts for a given segment. Requires subscription.", "parameters": [ { "name": "segment_id", "in": "query", "description": "The identifier of the segment.", "required": true, "type": "integer" }, { "name": "start_date_local", "in": "query", "description": "ISO 8601 formatted date time.", "type": "string", "format": "date-time" }, { "name": "end_date_local", "in": "query", "description": "ISO 8601 formatted date time.", "type": "string", "format": "date-time" }, { "$ref": "#/parameters/perPage", "in": "query" } ], "tags": [ "SegmentEfforts" ], "responses": { "200": { "description": "List of segment efforts.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/segment_effort.json#/DetailedSegmentEffort" } }, "examples": { "application/json": [ { "id": 123456789, "resource_state": 2, "name": "Alpe d'Huez", "activity": { "id": 1234567890, "resource_state": 1 }, "athlete": { "id": 123445678689, "resource_state": 1 }, "elapsed_time": 1657, "moving_time": 1642, "start_date": "2007-09-15T08:15:29Z", "start_date_local": "2007-09-15T09:15:29Z", "distance": 6148.92, "start_index": 1102, "end_index": 1366, "device_watts": false, "average_watts": 220.2, "segment": { "id": 788127, "resource_state": 2, "name": "Alpe d'Huez", "activity_type": "Ride", "distance": 6297.46, "average_grade": 4.8, "maximum_grade": 16.3, "elevation_high": 416, "elevation_low": 104.6, "start_latlng": [ 52.98501000581467, -3.1869720001197367 ], "end_latlng": [ 53.02204074375785, -3.2039630001245737 ], "climb_category": 2, "city": "Le Bourg D'Oisans", "state": "RA", "country": "France", "private": false, "hazardous": false, "starred": false }, "kom_rank": null, "pr_rank": null, "achievements": [] } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segments/explore": { "get": { "operationId": "exploreSegments", "summary": "Explore segments", "description": "Returns the top 10 segments matching a specified query.", "parameters": [ { "name": "bounds", "in": "query", "description": "The latitude and longitude for two points describing a rectangular boundary for the search: [southwest corner latitutde, southwest corner longitude, northeast corner latitude, northeast corner longitude]", "required": true, "type": "array", "items": { "type": "number", "format": "float" }, "collectionFormat": "csv", "minItems": 4, "maxItems": 4 }, { "name": "activity_type", "in": "query", "description": "Desired activity type.", "type": "string", "enum": [ "running", "riding" ] }, { "name": "min_cat", "in": "query", "description": "The minimum climbing category.", "type": "integer", "minimum": 0, "maximum": 5 }, { "name": "max_cat", "in": "query", "description": "The maximum climbing category.", "type": "integer", "minimum": 0, "maximum": 5 } ], "tags": [ "Segments" ], "responses": { "200": { "description": "List of matching segments.", "schema": { "$ref": "https://developers.strava.com/swagger/segment.json#/ExplorerResponse" }, "examples": { "application/json": { "segments": [ { "id": 229781, "resource_state": 2, "name": "Hawk Hill", "climb_category": 1, "climb_category_desc": "4", "avg_grade": 5.7, "start_latlng": [ 37.8331119, -122.4834356 ], "end_latlng": [ 37.8280722, -122.4981393 ], "elev_difference": 152.8, "distance": 2684.8, "points": "}g|eFnpqjVl@En@Md@HbAd@d@^h@Xx@VbARjBDh@OPQf@w@d@k@XKXDFPH\\EbGT`AV`@v@|@NTNb@?XOb@cAxAWLuE@eAFMBoAv@eBt@q@b@}@tAeAt@i@dAC`AFZj@dB?~@[h@MbAVn@b@b@\\d@Eh@Qb@_@d@eB|@c@h@WfBK|AMpA?VF\\\\t@f@t@h@j@|@b@hCb@b@XTd@Bl@GtA?jAL`ALp@Tr@RXd@Rx@Pn@^Zh@Tx@Zf@`@FTCzDy@f@Yx@m@n@Op@VJr@", "starred": false } ] } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segment_efforts/{id}": { "get": { "operationId": "getSegmentEffortById", "summary": "Get Segment Effort", "description": "Returns a segment effort from an activity that is owned by the authenticated athlete. Requires subscription.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the segment effort.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "SegmentEfforts" ], "responses": { "200": { "description": "Representation of a segment effort.", "schema": { "$ref": "https://developers.strava.com/swagger/segment_effort.json#/DetailedSegmentEffort" }, "examples": { "application/json": { "id": 1234556789, "resource_state": 3, "name": "Alpe d'Huez", "activity": { "id": 3454504, "resource_state": 1 }, "athlete": { "id": 54321, "resource_state": 1 }, "elapsed_time": 381, "moving_time": 340, "start_date": "2018-02-12T16:12:41Z", "start_date_local": "2018-02-12T08:12:41Z", "distance": 83, "start_index": 65, "end_index": 83, "segment": { "id": 63450, "resource_state": 2, "name": "Alpe d'Huez", "activity_type": "Run", "distance": 780.35, "average_grade": -0.5, "maximum_grade": 0, "elevation_high": 21, "elevation_low": 17.2, "start_latlng": [ 37.808407654682, -122.426682919323 ], "end_latlng": [ 37.808297909724, -122.421324329674 ], "climb_category": 0, "city": "San Francisco", "state": "CA", "country": "United States", "private": false, "hazardous": false, "starred": false }, "kom_rank": null, "pr_rank": null, "achievements": [], "athlete_segment_stats": { "pr_elapsed_time": 212, "pr_date": "2015-02-12", "effort_count": 149 } } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities": { "post": { "operationId": "createActivity", "summary": "Create an Activity", "description": "Creates a manual activity for an athlete, requires activity:write scope.", "parameters": [ { "name": "name", "in": "formData", "description": "The name of the activity.", "required": true, "type": "string" }, { "name": "type", "in": "formData", "description": "Type of activity. For example - Run, Ride etc.", "type": "string", "required": false }, { "name": "sport_type", "in": "formData", "description": "Sport type of activity. For example - Run, MountainBikeRide, Ride, etc.", "type": "string", "required": true }, { "name": "start_date_local", "in": "formData", "description": "ISO 8601 formatted date time.", "type": "string", "format": "date-time", "required": true }, { "name": "elapsed_time", "in": "formData", "description": "In seconds.", "type": "integer", "required": true }, { "name": "description", "in": "formData", "description": "Description of the activity.", "type": "string", "required": false }, { "name": "distance", "in": "formData", "description": "In meters.", "type": "number", "format": "float", "required": false }, { "name": "trainer", "in": "formData", "description": "Set to 1 to mark as a trainer activity.", "type": "integer", "required": false }, { "name": "commute", "in": "formData", "description": "Set to 1 to mark as commute.", "type": "integer", "required": false } ], "tags": [ "Activities" ], "responses": { "201": { "description": "The activity's detailed representation.", "schema": { "$ref": "https://developers.strava.com/swagger/activity.json#/DetailedActivity" }, "examples": { "application/json": { "id": 123456778928065, "resource_state": 3, "external_id": null, "upload_id": null, "athlete": { "id": 12343545645788, "resource_state": 1 }, "name": "Chill Day", "distance": 0, "moving_time": 18373, "elapsed_time": 18373, "total_elevation_gain": 0, "type": "Ride", "sport_type": "MountainBikeRide", "start_date": "2018-02-20T18:02:13Z", "start_date_local": "2018-02-20T10:02:13Z", "timezone": "(GMT-08:00) America/Los_Angeles", "utc_offset": -28800, "achievement_count": 0, "kudos_count": 0, "comment_count": 0, "athlete_count": 1, "photo_count": 0, "map": { "id": "a12345678908766", "polyline": null, "resource_state": 3 }, "trainer": false, "commute": false, "manual": true, "private": false, "flagged": false, "gear_id": "b453542543", "from_accepted_tag": null, "average_speed": 0, "max_speed": 0, "device_watts": false, "has_heartrate": false, "pr_count": 0, "total_photo_count": 0, "has_kudoed": false, "workout_type": null, "description": null, "calories": 0, "segment_efforts": [] } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities/{id}": { "get": { "operationId": "getActivityById", "summary": "Get Activity", "description": "Returns the given activity that is owned by the authenticated athlete. Requires activity:read for Everyone and Followers activities. Requires activity:read_all for Only Me activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" }, { "name": "include_all_efforts", "in": "query", "description": "To include all segments efforts.", "type": "boolean" } ], "tags": [ "Activities" ], "responses": { "200": { "description": "The activity's detailed representation.", "schema": { "$ref": "https://developers.strava.com/swagger/activity.json#/DetailedActivity" }, "examples": { "application/json": { "id": 12345678987654321, "resource_state": 3, "external_id": "garmin_push_12345678987654321", "upload_id": 98765432123456789, "athlete": { "id": 134815, "resource_state": 1 }, "name": "Happy Friday", "distance": 28099, "moving_time": 4207, "elapsed_time": 4410, "total_elevation_gain": 516, "type": "Ride", "sport_type": "MountainBikeRide", "start_date": "2018-02-16T14:52:54Z", "start_date_local": "2018-02-16T06:52:54Z", "timezone": "(GMT-08:00) America/Los_Angeles", "utc_offset": -28800, "start_latlng": [ 37.83, -122.26 ], "end_latlng": [ 37.83, -122.26 ], "achievement_count": 0, "kudos_count": 19, "comment_count": 0, "athlete_count": 1, "photo_count": 0, "map": { "id": "a1410355832", "polyline": "ki{eFvqfiVqAWQIGEEKAYJgBVqDJ{BHa@jAkNJw@Pw@V{APs@^aABQAOEQGKoJ_FuJkFqAo@{A}@sH{DiAs@Q]?WVy@`@oBt@_CB]KYMMkB{AQEI@WT{BlE{@zAQPI@ICsCqA_BcAeCmAaFmCqIoEcLeG}KcG}A}@cDaBiDsByAkAuBqBi@y@_@o@o@kB}BgIoA_EUkAMcACa@BeBBq@LaAJe@b@uA`@_AdBcD`@iAPq@RgALqAB{@EqAyAoOCy@AmCBmANqBLqAZkB\\iCPiBJwCCsASiCq@iD]eA]y@[i@w@mAa@i@k@g@kAw@i@Ya@Q]EWFMLa@~BYpAFNpA`Aj@n@X`@V`AHh@JfB@xAMvAGZGHIDIAWOEQNcC@sACYK[MSOMe@QKKKYOs@UYQISCQ?Q@WNo@r@OHGAGCKOQ_BU}@MQGG]Io@@c@FYNg@d@s@d@ODQAMOMaASs@_@a@SESAQDqBn@a@RO?KK?UBU\\kA@Y?WMo@Iy@GWQ_@WSSGg@AkABQB_Ap@_A^o@b@Q@o@IS@OHi@n@OFS?OI}@iAQMQGQC}@DOIIUK{@IUOMyBo@kASOKIQCa@L[|AgATWN[He@?QKw@FOPCh@Fx@l@TDLELKl@aAHIJEX@r@ZTDV@LENQVg@RkA@c@MeA?WFOPMf@Ej@Fj@@LGHKDM?_@_@iC?a@HKRIl@NT?FCHMFW?YEYGWQa@GYBiAIq@Gq@L_BHSHK|@WJETSLQZs@z@_A~@uA^U`@G\\CRB\\Tl@p@Th@JZ^bB`@lAHLXVLDP?LGFSKiDBo@d@wBVi@R]VYVE\\@`@Lh@Fh@CzAk@RSDQA]GYe@eAGWSiBAWBWBIJORK`@KPOPSTg@h@}Ad@o@F[E_@EGMKUGmAEYGMIMYKs@?a@J}@@_BD_@HQJMx@e@LKHKHWAo@UoAAWFmAH}@?w@C[YwAAc@HSNM|Ao@rA}@zAq@`@a@j@eAxAuBXQj@MXSR[b@gAFg@?YISOGaAHi@Xw@v@_@d@WRSFqARUHQJc@d@m@`A[VSFUBcAEU@WFULUPa@v@Y~@UrBc@dBI~@?l@P~ABt@N`HEjA]zAEp@@p@TrBCl@CTQb@k@dAg@jAU^KJYLK@k@A[Js@d@a@b@]RgBl@[FMAw@[]G]?m@D_@F]P[Vu@t@[TMF_@Do@E_@@q@P]PWZUZw@vAkAlAGJOj@IlAMd@OR{@p@a@d@sBpD]v@a@`Aa@n@]TODgBVk@Pe@^cBfBc@Rs@La@RSPm@|@wCpDS^Wp@QZML{@l@qBbCYd@k@lAIVCZBZNTr@`@RRHZANIZQPKDW@e@CaASU?I@YTKRQx@@\\VmALYRQLCL?v@P|@D\\GJEFKDM@OCa@COOYIGm@YMUCM@]JYr@uAx@kAt@}@jAeAPWbAkBj@s@bAiAz@oAj@m@VQlAc@VQ~@aA`Au@p@Q`AIv@MZORUV_@p@iB|AoCh@q@dAaANUNWH[N{AJ[^m@t@_Av@wA\\a@`@W`@In@Al@B^E`@Wl@u@\\[VQ\\K`@Eb@?R@dAZP@d@CRExAs@\\Yt@{@LG\\MjAATINOXo@d@kAl@_AHYBOCe@QiBCm@Fq@\\wADo@AyGEeBWuB@YHu@Tu@Lk@VcCTo@d@aA\\WJE`@G~@FP?VI\\U~@sANO`@SfAMj@U\\WjAsAXS`@UNENALBHFFL?^Ml@Uj@]b@q@RUJSPkChEc@XcAb@sA|@]PaA\\OJKNER?TDTNj@Jn@?p@OfC@ZR`B@VCV_@n@{@l@WbACv@OlABnAPl@LNNHbBBNBLFFJ@^GLg@x@i@|AMP[X}@XOJKPET?l@LhAFXp@fBDRCd@S\\_@Ps@PQ@}A]S?QDe@V]b@MR[fAKt@ErAF~CANILYDKGIKe@{@Yy@e@sB[gA[c@e@YUCU?WBUHUNQPq@`AiArAMV[^e@Zc@JQJKNMz@?r@Bb@PfAAfA@VVbADn@E`@KHSEe@SMAKDKFM\\^dDCh@m@LoAQ_@@MFOZLfBEl@QbASd@KLQBOAaAc@QAQ@QHc@v@ONMJOBOCg@c@]O[EMBKFGL?RHv@ARERGNe@h@{@h@WVGNDt@JLNFPFz@LdBf@f@PJNHPF`ADPJJJDl@I`@B^Tp@bALJNDNALIf@i@PGPCt@DNE`@Uv@[dAw@RITGRCtAARBPJLPJRZxB?VEX_@vAAR?RDNHJJBh@UnBm@h@IRDRJNNJPNbBFRJLLBLCzAmAd@Uf@Gf@?P@PFJNHPFTH`BDTHNJJJ@LG`@m@^YPER@RDPHNNJRLn@HRLN^VNPHTFX@\\UlDFb@FHh@NP@HKPsB?}ASkCQ{@[y@q@}@cA{@KOCQDa@t@{CFGJCf@Nl@ZtA~@r@p@`@h@rAxBd@rA\\fARdAPjANrB?f@AtBCd@QfBkAjJOlBChA?rBFrBNlBdAfKFzAC~@Iz@Mz@Sv@s@jBmAxBi@hAWt@Sv@Qx@O`BA`@?dAPfBVpAd@`BfBlFf@fBdA~Cr@pAz@fApBhBjAt@H?IL?FBFJLx@^lHvDvh@~XnElCbAd@pGhDbAb@nAr@`Ad@`GhDnBbAxCbBrWhNJJDPARGP_@t@Qh@]pAUtAoA`Ny@jJApBBNFLJFJBv@Hb@HBF?\\", "resource_state": 3, "summary_polyline": "ki{eFvqfiVsBmA`Feh@qg@iX`B}JeCcCqGjIq~@kf@cM{KeHeX`@_GdGkSeBiXtB}YuEkPwFyDeAzAe@pC~DfGc@bIOsGmCcEiD~@oBuEkFhBcBmDiEfAVuDiAuD}NnDaNiIlCyDD_CtJKv@wGhD]YyEzBo@g@uKxGmHpCGtEtI~AuLrHkAcAaIvEgH_EaDR_FpBuBg@sNxHqEtHgLoTpIiCzKNr[sB|Es\\`JyObYeMbGsMnPsAfDxAnD}DBu@bCx@{BbEEyAoD`AmChNoQzMoGhOwX|[yIzBeFKg[zAkIdU_LiHxK}HzEh@vM_BtBg@xGzDbCcF~GhArHaIfByAhLsDiJuC?_HbHd@nL_Cz@ZnEkDDy@hHwJLiCbIrNrIvN_EfAjDWlEnEiAfBxDlFkBfBtEfDaAzBvDKdFx@|@XgJmDsHhAgD`GfElEzOwBnYdBxXgGlSc@bGdHpW|HdJztBnhAgFxc@HnCvBdA" }, "trainer": false, "commute": false, "manual": false, "private": false, "flagged": false, "gear_id": "b12345678987654321", "from_accepted_tag": false, "average_speed": 6.679, "max_speed": 18.5, "average_cadence": 78.5, "average_temp": 4, "average_watts": 185.5, "weighted_average_watts": 230, "kilojoules": 780.5, "device_watts": true, "has_heartrate": false, "max_watts": 743, "elev_high": 446.6, "elev_low": 17.2, "pr_count": 0, "total_photo_count": 2, "has_kudoed": false, "workout_type": 10, "suffer_score": null, "description": "", "calories": 870.2, "segment_efforts": [ { "id": 12345678987654321, "resource_state": 2, "name": "Tunnel Rd.", "activity": { "id": 12345678987654321, "resource_state": 1 }, "athlete": { "id": 134815, "resource_state": 1 }, "elapsed_time": 2038, "moving_time": 2038, "start_date": "2018-02-16T14:56:25Z", "start_date_local": "2018-02-16T06:56:25Z", "distance": 9434.8, "start_index": 211, "end_index": 2246, "average_cadence": 78.6, "device_watts": true, "average_watts": 237.6, "segment": { "id": 673683, "resource_state": 2, "name": "Tunnel Rd.", "activity_type": "Ride", "distance": 9220.7, "average_grade": 4.2, "maximum_grade": 25.8, "elevation_high": 426.5, "elevation_low": 43.4, "start_latlng": [ 37.8346153, -122.2520872 ], "end_latlng": [ 37.8476261, -122.2008944 ], "climb_category": 3, "city": "Oakland", "state": "CA", "country": "United States", "private": false, "hazardous": false, "starred": false }, "kom_rank": null, "pr_rank": null, "achievements": [], "hidden": false } ], "splits_metric": [ { "distance": 1001.5, "elapsed_time": 141, "elevation_difference": 4.4, "moving_time": 141, "split": 1, "average_speed": 7.1, "pace_zone": 0 } ], "laps": [ { "id": 4479306946, "resource_state": 2, "name": "Lap 1", "activity": { "id": 1410355832, "resource_state": 1 }, "athlete": { "id": 134815, "resource_state": 1 }, "elapsed_time": 1573, "moving_time": 1569, "start_date": "2018-02-16T14:52:54Z", "start_date_local": "2018-02-16T06:52:54Z", "distance": 8046.72, "start_index": 0, "end_index": 1570, "total_elevation_gain": 276, "average_speed": 5.12, "max_speed": 9.5, "average_cadence": 78.6, "device_watts": true, "average_watts": 233.1, "lap_index": 1, "split": 1 } ], "gear": { "id": "b12345678987654321", "primary": true, "name": "Tarmac", "resource_state": 2, "distance": 32547610 }, "partner_brand_tag": null, "photos": { "primary": { "id": null, "unique_id": "3FDGKL3-204E-4867-9E8D-89FC79EAAE17", "urls": { "100": "https://dgtzuqphqg23d.cloudfront.net/Bv93zv5t_mr57v0wXFbY_JyvtucgmU5Ym6N9z_bKeUI-128x96.jpg", "600": "https://dgtzuqphqg23d.cloudfront.net/Bv93zv5t_mr57v0wXFbY_JyvtucgmU5Ym6N9z_bKeUI-768x576.jpg" }, "source": 1 }, "use_primary_photo": true, "count": 2 }, "highlighted_kudosers": [ { "destination_url": "strava://athletes/12345678987654321", "display_name": "Marianne V.", "avatar_url": "https://dgalywyr863hv.cloudfront.net/pictures/athletes/12345678987654321/12345678987654321/3/medium.jpg", "show_name": true } ], "hide_from_home": false, "device_name": "Garmin Edge 1030", "embed_token": "18e4615989b47dd4ff3dc711b0aa4502e4b311a9", "segment_leaderboard_opt_out": false, "leaderboard_opt_out": false } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } }, "put": { "operationId": "updateActivityById", "summary": "Update Activity", "description": "Updates the given activity that is owned by the authenticated athlete. Requires activity:write. Also requires activity:read_all in order to update Only Me activities", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" }, { "name": "body", "in": "body", "schema": { "$ref": "https://developers.strava.com/swagger/activity.json#/UpdatableActivity" } } ], "tags": [ "Activities" ], "responses": { "200": { "description": "The activity's detailed representation.", "schema": { "$ref": "https://developers.strava.com/swagger/activity.json#/DetailedActivity" }, "examples": { "application/json": { "id": 12345678987654321, "resource_state": 3, "external_id": "garmin_push_12345678987654321", "upload_id": 98765432123456789, "athlete": { "id": 134815, "resource_state": 1 }, "name": "Happy Friday", "distance": 28099, "moving_time": 4207, "elapsed_time": 4410, "total_elevation_gain": 516, "type": "Ride", "sport_type": "MountainBikeRide", "start_date": "2018-02-16T14:52:54Z", "start_date_local": "2018-02-16T06:52:54Z", "timezone": "(GMT-08:00) America/Los_Angeles", "utc_offset": -28800, "start_latlng": [ 37.83, -122.26 ], "end_latlng": [ 37.83, -122.26 ], "location_city": null, "location_state": null, "location_country": "United States", "achievement_count": 0, "kudos_count": 19, "comment_count": 0, "athlete_count": 1, "photo_count": 0, "map": { "id": "a1410355832", "polyline": "ki{eFvqfiVqAWQIGEEKAYJgBVqDJ{BHa@jAkNJw@Pw@V{APs@^aABQAOEQGKoJ_FuJkFqAo@{A}@sH{DiAs@Q]?WVy@`@oBt@_CB]KYMMkB{AQEI@WT{BlE{@zAQPI@ICsCqA_BcAeCmAaFmCqIoEcLeG}KcG}A}@cDaBiDsByAkAuBqBi@y@_@o@o@kB}BgIoA_EUkAMcACa@BeBBq@LaAJe@b@uA`@_AdBcD`@iAPq@RgALqAB{@EqAyAoOCy@AmCBmANqBLqAZkB\\iCPiBJwCCsASiCq@iD]eA]y@[i@w@mAa@i@k@g@kAw@i@Ya@Q]EWFMLa@~BYpAFNpA`Aj@n@X`@V`AHh@JfB@xAMvAGZGHIDIAWOEQNcC@sACYK[MSOMe@QKKKYOs@UYQISCQ?Q@WNo@r@OHGAGCKOQ_BU}@MQGG]Io@@c@FYNg@d@s@d@ODQAMOMaASs@_@a@SESAQDqBn@a@RO?KK?UBU\\kA@Y?WMo@Iy@GWQ_@WSSGg@AkABQB_Ap@_A^o@b@Q@o@IS@OHi@n@OFS?OI}@iAQMQGQC}@DOIIUK{@IUOMyBo@kASOKIQCa@L[|AgATWN[He@?QKw@FOPCh@Fx@l@TDLELKl@aAHIJEX@r@ZTDV@LENQVg@RkA@c@MeA?WFOPMf@Ej@Fj@@LGHKDM?_@_@iC?a@HKRIl@NT?FCHMFW?YEYGWQa@GYBiAIq@Gq@L_BHSHK|@WJETSLQZs@z@_A~@uA^U`@G\\CRB\\Tl@p@Th@JZ^bB`@lAHLXVLDP?LGFSKiDBo@d@wBVi@R]VYVE\\@`@Lh@Fh@CzAk@RSDQA]GYe@eAGWSiBAWBWBIJORK`@KPOPSTg@h@}Ad@o@F[E_@EGMKUGmAEYGMIMYKs@?a@J}@@_BD_@HQJMx@e@LKHKHWAo@UoAAWFmAH}@?w@C[YwAAc@HSNM|Ao@rA}@zAq@`@a@j@eAxAuBXQj@MXSR[b@gAFg@?YISOGaAHi@Xw@v@_@d@WRSFqARUHQJc@d@m@`A[VSFUBcAEU@WFULUPa@v@Y~@UrBc@dBI~@?l@P~ABt@N`HEjA]zAEp@@p@TrBCl@CTQb@k@dAg@jAU^KJYLK@k@A[Js@d@a@b@]RgBl@[FMAw@[]G]?m@D_@F]P[Vu@t@[TMF_@Do@E_@@q@P]PWZUZw@vAkAlAGJOj@IlAMd@OR{@p@a@d@sBpD]v@a@`Aa@n@]TODgBVk@Pe@^cBfBc@Rs@La@RSPm@|@wCpDS^Wp@QZML{@l@qBbCYd@k@lAIVCZBZNTr@`@RRHZANIZQPKDW@e@CaASU?I@YTKRQx@@\\VmALYRQLCL?v@P|@D\\GJEFKDM@OCa@COOYIGm@YMUCM@]JYr@uAx@kAt@}@jAeAPWbAkBj@s@bAiAz@oAj@m@VQlAc@VQ~@aA`Au@p@Q`AIv@MZORUV_@p@iB|AoCh@q@dAaANUNWH[N{AJ[^m@t@_Av@wA\\a@`@W`@In@Al@B^E`@Wl@u@\\[VQ\\K`@Eb@?R@dAZP@d@CRExAs@\\Yt@{@LG\\MjAATINOXo@d@kAl@_AHYBOCe@QiBCm@Fq@\\wADo@AyGEeBWuB@YHu@Tu@Lk@VcCTo@d@aA\\WJE`@G~@FP?VI\\U~@sANO`@SfAMj@U\\WjAsAXS`@UNENALBHFFL?^Ml@Uj@]b@q@RUJSPkChEc@XcAb@sA|@]PaA\\OJKNER?TDTNj@Jn@?p@OfC@ZR`B@VCV_@n@{@l@WbACv@OlABnAPl@LNNHbBBNBLFFJ@^GLg@x@i@|AMP[X}@XOJKPET?l@LhAFXp@fBDRCd@S\\_@Ps@PQ@}A]S?QDe@V]b@MR[fAKt@ErAF~CANILYDKGIKe@{@Yy@e@sB[gA[c@e@YUCU?WBUHUNQPq@`AiArAMV[^e@Zc@JQJKNMz@?r@Bb@PfAAfA@VVbADn@E`@KHSEe@SMAKDKFM\\^dDCh@m@LoAQ_@@MFOZLfBEl@QbASd@KLQBOAaAc@QAQ@QHc@v@ONMJOBOCg@c@]O[EMBKFGL?RHv@ARERGNe@h@{@h@WVGNDt@JLNFPFz@LdBf@f@PJNHPF`ADPJJJDl@I`@B^Tp@bALJNDNALIf@i@PGPCt@DNE`@Uv@[dAw@RITGRCtAARBPJLPJRZxB?VEX_@vAAR?RDNHJJBh@UnBm@h@IRDRJNNJPNbBFRJLLBLCzAmAd@Uf@Gf@?P@PFJNHPFTH`BDTHNJJJ@LG`@m@^YPER@RDPHNNJRLn@HRLN^VNPHTFX@\\UlDFb@FHh@NP@HKPsB?}ASkCQ{@[y@q@}@cA{@KOCQDa@t@{CFGJCf@Nl@ZtA~@r@p@`@h@rAxBd@rA\\fARdAPjANrB?f@AtBCd@QfBkAjJOlBChA?rBFrBNlBdAfKFzAC~@Iz@Mz@Sv@s@jBmAxBi@hAWt@Sv@Qx@O`BA`@?dAPfBVpAd@`BfBlFf@fBdA~Cr@pAz@fApBhBjAt@H?IL?FBFJLx@^lHvDvh@~XnElCbAd@pGhDbAb@nAr@`Ad@`GhDnBbAxCbBrWhNJJDPARGP_@t@Qh@]pAUtAoA`Ny@jJApBBNFLJFJBv@Hb@HBF?\\", "resource_state": 3, "summary_polyline": "ki{eFvqfiVsBmA`Feh@qg@iX`B}JeCcCqGjIq~@kf@cM{KeHeX`@_GdGkSeBiXtB}YuEkPwFyDeAzAe@pC~DfGc@bIOsGmCcEiD~@oBuEkFhBcBmDiEfAVuDiAuD}NnDaNiIlCyDD_CtJKv@wGhD]YyEzBo@g@uKxGmHpCGtEtI~AuLrHkAcAaIvEgH_EaDR_FpBuBg@sNxHqEtHgLoTpIiCzKNr[sB|Es\\`JyObYeMbGsMnPsAfDxAnD}DBu@bCx@{BbEEyAoD`AmChNoQzMoGhOwX|[yIzBeFKg[zAkIdU_LiHxK}HzEh@vM_BtBg@xGzDbCcF~GhArHaIfByAhLsDiJuC?_HbHd@nL_Cz@ZnEkDDy@hHwJLiCbIrNrIvN_EfAjDWlEnEiAfBxDlFkBfBtEfDaAzBvDKdFx@|@XgJmDsHhAgD`GfElEzOwBnYdBxXgGlSc@bGdHpW|HdJztBnhAgFxc@HnCvBdA" }, "trainer": false, "commute": false, "manual": false, "private": false, "flagged": false, "gear_id": "b12345678987654321", "from_accepted_tag": false, "average_speed": 6.679, "max_speed": 18.5, "average_cadence": 78.5, "average_temp": 4, "average_watts": 185.5, "weighted_average_watts": 230, "kilojoules": 780.5, "device_watts": true, "has_heartrate": false, "max_watts": 743, "elev_high": 446.6, "elev_low": 17.2, "pr_count": 0, "total_photo_count": 2, "has_kudoed": false, "workout_type": 10, "suffer_score": null, "description": "", "calories": 870.2, "segment_efforts": [ { "id": 12345678987654321, "resource_state": 2, "name": "Tunnel Rd.", "activity": { "id": 12345678987654321, "resource_state": 1 }, "athlete": { "id": 12345678987654321, "resource_state": 1 }, "elapsed_time": 2038, "moving_time": 2038, "start_date": "2018-02-16T14:56:25Z", "start_date_local": "2018-02-16T06:56:25Z", "distance": 9434.8, "start_index": 211, "end_index": 2246, "average_cadence": 78.6, "device_watts": true, "average_watts": 237.6, "segment": { "id": 673683, "resource_state": 2, "name": "Tunnel Rd.", "activity_type": "Ride", "distance": 9220.7, "average_grade": 4.2, "maximum_grade": 25.8, "elevation_high": 426.5, "elevation_low": 43.4, "start_latlng": [ 37.8346153, -122.2520872 ], "end_latlng": [ 37.8476261, -122.2008944 ], "climb_category": 3, "city": "Oakland", "state": "CA", "country": "United States", "private": false, "hazardous": false, "starred": false }, "kom_rank": null, "pr_rank": null, "achievements": [], "hidden": false } ], "splits_metric": [ { "distance": 1001.5, "elapsed_time": 141, "elevation_difference": 4.4, "moving_time": 141, "split": 1, "average_speed": 7.1, "pace_zone": 0 } ], "laps": [ { "id": 4479306946, "resource_state": 2, "name": "Lap 1", "activity": { "id": 1410355832, "resource_state": 1 }, "athlete": { "id": 134815, "resource_state": 1 }, "elapsed_time": 1573, "moving_time": 1569, "start_date": "2018-02-16T14:52:54Z", "start_date_local": "2018-02-16T06:52:54Z", "distance": 8046.72, "start_index": 0, "end_index": 1570, "total_elevation_gain": 276, "average_speed": 5.12, "max_speed": 9.5, "average_cadence": 78.6, "device_watts": true, "average_watts": 233.1, "lap_index": 1, "split": 1 } ], "gear": { "id": "b12345678987654321", "primary": true, "name": "Tarmac", "resource_state": 2, "distance": 32547610 }, "partner_brand_tag": null, "photos": { "primary": { "id": null, "unique_id": "3FDGKL3-204E-4867-9E8D-89FC79EAAE17", "urls": { "100": "https://dgtzuqphqg23d.cloudfront.net/Bv93zv5t_mr57v0wXFbY_JyvtucgmU5Ym6N9z_bKeUI-128x96.jpg", "600": "https://dgtzuqphqg23d.cloudfront.net/Bv93zv5t_mr57v0wXFbY_JyvtucgmU5Ym6N9z_bKeUI-768x576.jpg" }, "source": 1 }, "use_primary_photo": true, "count": 2 }, "highlighted_kudosers": [ { "destination_url": "strava://athletes/12345678987654321", "display_name": "Marianne V.", "avatar_url": "https://dgalywyr863hv.cloudfront.net/pictures/athletes/12345678987654321/12345678987654321/3/medium.jpg", "show_name": true } ], "hide_from_home": false, "device_name": "Garmin Edge 1030", "embed_token": "18e4615989b47dd4ff3dc711b0aa4502e4b311a9", "segment_leaderboard_opt_out": false, "leaderboard_opt_out": false } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/athlete/activities": { "get": { "operationId": "getLoggedInAthleteActivities", "summary": "List Athlete Activities", "description": "Returns the activities of an athlete for a specific identifier. Requires activity:read. Only Me activities will be filtered out unless requested by a token with activity:read_all.", "parameters": [ { "name": "before", "in": "query", "description": "An epoch timestamp to use for filtering activities that have taken place before a certain time.", "type": "integer" }, { "name": "after", "in": "query", "description": "An epoch timestamp to use for filtering activities that have taken place after a certain time.", "type": "integer" }, { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Activities" ], "responses": { "200": { "description": "The authenticated athlete's activities", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/activity.json#/SummaryActivity" } }, "examples": { "application/json": [ { "resource_state": 2, "athlete": { "id": 134815, "resource_state": 1 }, "name": "Happy Friday", "distance": 24931.4, "moving_time": 4500, "elapsed_time": 4500, "total_elevation_gain": 0, "type": "Ride", "sport_type": "MountainBikeRide", "workout_type": null, "id": 154504250376823, "external_id": "garmin_push_12345678987654321", "upload_id": 987654321234567891234, "start_date": "2018-05-02T12:15:09Z", "start_date_local": "2018-05-02T05:15:09Z", "timezone": "(GMT-08:00) America/Los_Angeles", "utc_offset": -25200, "start_latlng": null, "end_latlng": null, "location_city": null, "location_state": null, "location_country": "United States", "achievement_count": 0, "kudos_count": 3, "comment_count": 1, "athlete_count": 1, "photo_count": 0, "map": { "id": "a12345678987654321", "summary_polyline": null, "resource_state": 2 }, "trainer": true, "commute": false, "manual": false, "private": false, "flagged": false, "gear_id": "b12345678987654321", "from_accepted_tag": false, "average_speed": 5.54, "max_speed": 11, "average_cadence": 67.1, "average_watts": 175.3, "weighted_average_watts": 210, "kilojoules": 788.7, "device_watts": true, "has_heartrate": true, "average_heartrate": 140.3, "max_heartrate": 178, "max_watts": 406, "pr_count": 0, "total_photo_count": 1, "has_kudoed": false, "suffer_score": 82 }, { "resource_state": 2, "athlete": { "id": 167560, "resource_state": 1 }, "name": "Bondcliff", "distance": 23676.5, "moving_time": 5400, "elapsed_time": 5400, "total_elevation_gain": 0, "type": "Ride", "sport_type": "MountainBikeRide", "workout_type": null, "id": 1234567809, "external_id": "garmin_push_12345678987654321", "upload_id": 1234567819, "start_date": "2018-04-30T12:35:51Z", "start_date_local": "2018-04-30T05:35:51Z", "timezone": "(GMT-08:00) America/Los_Angeles", "utc_offset": -25200, "start_latlng": null, "end_latlng": null, "location_city": null, "location_state": null, "location_country": "United States", "achievement_count": 0, "kudos_count": 4, "comment_count": 0, "athlete_count": 1, "photo_count": 0, "map": { "id": "a12345689", "summary_polyline": null, "resource_state": 2 }, "trainer": true, "commute": false, "manual": false, "private": false, "flagged": false, "gear_id": "b12345678912343", "from_accepted_tag": false, "average_speed": 4.385, "max_speed": 8.8, "average_cadence": 69.8, "average_watts": 200, "weighted_average_watts": 214, "kilojoules": 1080, "device_watts": true, "has_heartrate": true, "average_heartrate": 152.4, "max_heartrate": 183, "max_watts": 403, "pr_count": 0, "total_photo_count": 1, "has_kudoed": false, "suffer_score": 162 } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities/{id}/laps": { "get": { "operationId": "getLapsByActivityId", "summary": "List Activity Laps", "description": "Returns the laps of an activity identified by an identifier. Requires activity:read for Everyone and Followers activities. Requires activity:read_all for Only Me activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Activities" ], "responses": { "200": { "description": "Activity Laps.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/lap.json#/Lap" } }, "examples": { "application/json": [ { "id": 12345678987654321, "resource_state": 2, "name": "Lap 1", "activity": { "id": 12345678987654321, "resource_state": 1 }, "athlete": { "id": 12345678987654321, "resource_state": 1 }, "elapsed_time": 1691, "moving_time": 1587, "start_date": "2018-02-08T14:13:37Z", "start_date_local": "2018-02-08T06:13:37Z", "distance": 8046.72, "start_index": 0, "end_index": 1590, "total_elevation_gain": 270, "average_speed": 4.76, "max_speed": 9.4, "average_cadence": 79, "device_watts": true, "average_watts": 228.2, "lap_index": 1, "split": 1 } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities/{id}/zones": { "get": { "operationId": "getZonesByActivityId", "summary": "Get Activity Zones", "description": "Summit Feature. Returns the zones of a given activity. Requires activity:read for Everyone and Followers activities. Requires activity:read_all for Only Me activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Activities" ], "responses": { "200": { "description": "Activity Zones.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/zones.json#/ActivityZone" } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities/{id}/comments": { "get": { "operationId": "getCommentsByActivityId", "summary": "List Activity Comments", "description": "Returns the comments on the given activity. Requires activity:read for Everyone and Followers activities. Requires activity:read_all for Only Me activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" }, { "name": "page", "in": "query", "description": "Deprecated. Prefer to use after_cursor.", "type": "integer" }, { "name": "per_page", "in": "query", "description": "Deprecated. Prefer to use page_size.", "type": "integer", "default": 30 }, { "name": "page_size", "in": "query", "description": "Number of items per page. Defaults to 30.", "type": "integer", "default": 30 }, { "name": "after_cursor", "in": "query", "description": "Cursor of the last item in the previous page of results, used to request the subsequent page of results. When omitted, the first page of results is fetched.", "type": "string" } ], "tags": [ "Activities" ], "responses": { "200": { "description": "Comments.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/comment.json#/Comment" } }, "examples": { "application/json": [ { "id": 12345678987654321, "activity_id": 12345678987654321, "post_id": null, "resource_state": 2, "text": "Good job and keep the cat pictures coming!", "mentions_metadata": null, "created_at": "2018-02-08T19:25:39Z", "athlete": { "firstname": "Peter", "lastname": "S" }, "cursor": "abc123%20" } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities/{id}/kudos": { "get": { "operationId": "getKudoersByActivityId", "summary": "List Activity Kudoers", "description": "Returns the athletes who kudoed an activity identified by an identifier. Requires activity:read for Everyone and Followers activities. Requires activity:read_all for Only Me activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" }, { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Activities" ], "responses": { "200": { "description": "Comments.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/athlete.json#/SummaryAthlete" } }, "examples": { "application/json": [ { "firstname": "Peter", "lastname": "S" } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/clubs/{id}": { "get": { "operationId": "getClubById", "summary": "Get Club", "description": "Returns a given club using its identifier.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the club.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Clubs" ], "responses": { "200": { "description": "The detailed representation of a club.", "schema": { "$ref": "https://developers.strava.com/swagger/club.json#/DetailedClub" }, "examples": { "application/json": { "id": 1, "resource_state": 3, "name": "Team Strava Cycling", "profile_medium": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/1/1582/4/medium.jpg", "profile": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/1/1582/4/large.jpg", "cover_photo": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/1/4328276/1/large.jpg", "cover_photo_small": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/1/4328276/1/small.jpg", "sport_type": "cycling", "activity_types": ["Ride", "VirtualRide", "EBikeRide", "Velomobile", "Handcycle"], "city": "San Francisco", "state": "California", "country": "United States", "private": true, "member_count": 116, "featured": false, "verified": false, "url": "team-strava-bike", "membership": "member", "admin": false, "owner": false, "description": "Private club for Cyclists who work at Strava.", "club_type": "company", "post_count": 29, "owner_id": 759, "following_count": 107 } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/clubs/{id}/members": { "get": { "operationId": "getClubMembersById", "summary": "List Club Members", "description": "Returns a list of the athletes who are members of a given club.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the club.", "required": true, "type": "integer", "format": "int64" }, { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Clubs" ], "responses": { "200": { "description": "A list of club athlete representations.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/athlete.json#/ClubAthlete" } }, "examples": { "application/json": [ { "resource_state": 2, "firstname": "Peter", "lastname": "S.", "membership": "member", "admin": false, "owner": false } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/clubs/{id}/admins": { "get": { "operationId": "getClubAdminsById", "summary": "List Club Administrators", "description": "Returns a list of the administrators of a given club.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the club.", "required": true, "type": "integer", "format": "int64" }, { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Clubs" ], "responses": { "200": { "description": "A list of summary athlete representations.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/athlete.json#/SummaryAthlete" } }, "examples": { "application/json": [ { "resource_state": 2, "firstname": "Peter", "lastname": "S." } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/clubs/{id}/activities": { "get": { "operationId": "getClubActivitiesById", "summary": "List Club Activities", "description": "Retrieve recent activities from members of a specific club. The authenticated athlete must belong to the requested club in order to hit this endpoint. Pagination is supported. Athlete profile visibility is respected for all activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the club.", "required": true, "type": "integer", "format": "int64" }, { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Clubs" ], "responses": { "200": { "description": "A list of activities.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/activity.json#/ClubActivity" } }, "examples": { "application/json": [ { "resource_state": 2, "athlete": { "resource_state": 2, "firstname": "Peter", "lastname": "S." }, "name": "World Championship", "distance": 2641.7, "moving_time": 577, "elapsed_time": 635, "total_elevation_gain": 8.8, "type": "Ride", "sport_type": "MountainBikeRide", "workout_type": null } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/athlete/clubs": { "get": { "operationId": "getLoggedInAthleteClubs", "summary": "List Athlete Clubs", "description": "Returns a list of the clubs whose membership includes the authenticated athlete.", "parameters": [ { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Clubs" ], "responses": { "200": { "description": "A list of summary club representations.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/club.json#/SummaryClub" } }, "examples": { "application/json": [ { "id": 231407, "resource_state": 2, "name": "The Strava Club", "profile_medium": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/231407/5319085/1/medium.jpg", "profile": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/231407/5319085/1/large.jpg", "cover_photo": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/231407/5098428/4/large.jpg", "cover_photo_small": "https://dgalywyr863hv.cloudfront.net/pictures/clubs/231407/5098428/4/small.jpg", "sport_type": "other", "city": "San Francisco", "state": "California", "country": "United States", "private": false, "member_count": 93151, "featured": false, "verified": true, "url": "strava" } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/gear/{id}": { "get": { "operationId": "getGearById", "summary": "Get Equipment", "description": "Returns an equipment using its identifier.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the gear.", "required": true, "type": "string" } ], "tags": [ "Gears" ], "responses": { "200": { "description": "A representation of the gear.", "schema": { "$ref": "https://developers.strava.com/swagger/gear.json#/DetailedGear" }, "examples": { "application/json": { "id": "b1231", "primary": false, "resource_state": 3, "distance": 388206, "brand_name": "BMC", "model_name": "Teammachine", "frame_type": 3, "description": "My Bike." } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/routes/{id}": { "get": { "operationId": "getRouteById", "summary": "Get Route", "description": "Returns a route using its identifier. Requires read_all scope for private routes.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the route.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Routes" ], "responses": { "200": { "description": "A representation of the route.", "schema": { "$ref": "https://developers.strava.com/swagger/route.json#/Route" } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/athletes/{id}/routes": { "get": { "operationId": "getRoutesByAthleteId", "summary": "List Athlete Routes", "description": "Returns a list of the routes created by the authenticated athlete. Private routes are filtered out unless requested by a token with read_all scope.", "parameters": [ { "$ref": "#/parameters/page" }, { "$ref": "#/parameters/perPage" } ], "tags": [ "Routes" ], "responses": { "200": { "description": "A representation of the route.", "schema": { "type": "array", "items": { "$ref": "https://developers.strava.com/swagger/route.json#/Route" } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/routes/{id}/export_gpx": { "get": { "operationId": "getRouteAsGPX", "summary": "Export Route GPX", "description": "Returns a GPX file of the route. Requires read_all scope for private routes.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the route.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Routes" ], "responses": { "200": { "description": "A GPX file with the route.", "content": { "application/gpx+xml" : { "schema": { "type" : "string", "format": "binary" } } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/routes/{id}/export_tcx": { "get": { "operationId": "getRouteAsTCX", "summary": "Export Route TCX", "description": "Returns a TCX file of the route. Requires read_all scope for private routes.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the route.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Routes" ], "responses": { "200": { "description": "A TCX file with the route.", "content": { "application/tcx+xml" : { "schema": { "type" : "string", "format": "binary" } } } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/uploads": { "post": { "operationId": "createUpload", "summary": "Upload Activity", "description": "Uploads a new data file to create an activity from. Requires activity:write scope.", "consumes": [ "multipart/form-data" ], "parameters": [ { "name": "file", "in": "formData", "type": "file", "description": "The uploaded file." }, { "name": "name", "in": "formData", "description": "The desired name of the resulting activity.", "type": "string" }, { "name": "description", "in": "formData", "description": "The desired description of the resulting activity.", "type": "string" }, { "name": "trainer", "in": "formData", "description": "Whether the resulting activity should be marked as having been performed on a trainer.", "type": "string" }, { "name": "commute", "in": "formData", "description": "Whether the resulting activity should be tagged as a commute.", "type": "string" }, { "name": "data_type", "in": "formData", "description": "The format of the uploaded file.", "type": "string", "enum": [ "fit", "fit.gz", "tcx", "tcx.gz", "gpx", "gpx.gz" ] }, { "name": "external_id", "in": "formData", "description": "The desired external identifier of the resulting activity.", "type": "string" } ], "tags": [ "Uploads" ], "responses": { "201": { "description": "A representation of the created upload.", "schema": { "$ref": "https://developers.strava.com/swagger/upload.json#/Upload" } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/uploads/{uploadId}": { "get": { "operationId": "getUploadById", "summary": "Get Upload", "description": "Returns an upload for a given identifier. Requires activity:write scope.", "parameters": [ { "name": "uploadId", "in": "path", "description": "The identifier of the upload.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Uploads" ], "responses": { "200": { "description": "Representation of the upload.", "schema": { "$ref": "https://developers.strava.com/swagger/upload.json#/Upload" } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/activities/{id}/streams": { "get": { "operationId": "getActivityStreams", "summary": "Get Activity Streams", "description": "Returns the given activity's streams. Requires activity:read scope. Requires activity:read_all scope for Only Me activities.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the activity.", "required": true, "type": "integer", "format": "int64" }, { "name": "keys", "in": "query", "description": "Desired stream types.", "required": true, "type": "array", "items": { "type": "string", "enum": [ "time", "distance", "latlng", "altitude", "velocity_smooth", "heartrate", "cadence", "watts", "temp", "moving", "grade_smooth" ] }, "collectionFormat": "csv", "minItems": 1 }, { "name": "key_by_type", "in": "query", "description": "Must be true.", "type": "boolean", "required": true, "default": true } ], "tags": [ "Streams" ], "responses": { "200": { "description": "The set of requested streams.", "schema": { "$ref": "https://developers.strava.com/swagger/stream.json#/StreamSet" }, "examples": { "application/json": [ { "type": "distance", "data": [ 2.9, 5.8, 8.5, 11.7, 15, 19, 23.2, 28, 32.8, 38.1, 43.8, 49.5 ], "series_type": "distance", "original_size": 12, "resolution": "high" } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segment_efforts/{id}/streams": { "get": { "operationId": "getSegmentEffortStreams", "summary": "Get Segment Effort Streams", "description": "Returns a set of streams for a segment effort completed by the authenticated athlete. Requires read_all scope.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the segment effort.", "required": true, "type": "integer", "format": "int64" }, { "name": "keys", "in": "query", "description": "The types of streams to return.", "required": true, "type": "array", "items": { "type": "string", "enum": [ "time", "distance", "latlng", "altitude", "velocity_smooth", "heartrate", "cadence", "watts", "temp", "moving", "grade_smooth" ] }, "collectionFormat": "csv", "minItems": 1 }, { "name": "key_by_type", "in": "query", "description": "Must be true.", "type": "boolean", "required": true, "default": true } ], "tags": [ "Streams" ], "responses": { "200": { "description": "The set of requested streams.", "schema": { "$ref": "https://developers.strava.com/swagger/stream.json#/StreamSet" }, "examples": { "application/json": [ { "type": "distance", "data": [ 904.5, 957.8, 963.1, 989.1, 1011.9, 1049.7, 1082.4, 1098.1, 1113.2, 1124.7, 1139.2, 1142.1, 1170.4, 1173 ], "series_type": "distance", "original_size": 14, "resolution": "high" } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/segments/{id}/streams": { "get": { "operationId": "getSegmentStreams", "summary": "Get Segment Streams", "description": "Returns the given segment's streams. Requires read_all scope for private segments.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the segment.", "required": true, "type": "integer", "format": "int64" }, { "name": "keys", "in": "query", "description": "The types of streams to return.", "required": true, "type": "array", "items": { "type": "string", "enum": [ "distance", "latlng", "altitude" ] }, "collectionFormat": "csv", "minItems": 1 }, { "name": "key_by_type", "in": "query", "description": "Must be true.", "type": "boolean", "required": true, "default": true } ], "tags": [ "Streams" ], "responses": { "200": { "description": "The set of requested streams.", "schema": { "$ref": "https://developers.strava.com/swagger/stream.json#/StreamSet" }, "examples": { "application/json": [ { "type": "latlng", "data": [ [ 37.833112, -122.483436 ], [ 37.832964, -122.483406 ] ], "series_type": "distance", "original_size": 2, "resolution": "high" }, { "type": "distance", "data": [ 0, 16.8 ], "series_type": "distance", "original_size": 2, "resolution": "high" }, { "type": "altitude", "data": [ 92.4, 93.4 ], "series_type": "distance", "original_size": 2, "resolution": "high" } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } }, "/routes/{id}/streams": { "get": { "operationId": "getRouteStreams", "summary": "Get Route Streams", "description": "Returns the given route's streams. Requires read_all scope for private routes.", "parameters": [ { "name": "id", "in": "path", "description": "The identifier of the route.", "required": true, "type": "integer", "format": "int64" } ], "tags": [ "Streams" ], "responses": { "200": { "description": "The set of requested streams.", "schema": { "$ref": "https://developers.strava.com/swagger/stream.json#/StreamSet" }, "examples": { "application/json": [ { "type": "latlng", "data": [ [ 37.833112, -122.483436 ], [ 37.832964, -122.483406 ] ] }, { "type": "distance", "data": [ 0, 16.8 ] }, { "type": "altitude", "data": [ 92.4, 93.4 ] } ] } }, "default": { "description": "Unexpected error.", "schema": { "$ref": "https://developers.strava.com/swagger/fault.json#/Fault" } } } } } } } stravalib-1.3.0/stravalib/tests/test.ini-example000066400000000000000000000007361442076457100217640ustar00rootroot00000000000000[write_tests] # Add a Strava access token for the write/upload functional tests # (This token must have been created with the ability to write data -- i.e. # scope=write or scope=private,write.) # # You can use the stravalib.tests.auth_responder server to help fetch this (see that module's documentation). access_token = xxxxxxxxxxxxxxxx [activity_tests] # Some tests require an activity which is owned by the athlete who obtained the # access_token above. activity_id = stravalib-1.3.0/stravalib/tests/unit/000077500000000000000000000000001442076457100176245ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/unit/__init__.py000066400000000000000000000000001442076457100217230ustar00rootroot00000000000000stravalib-1.3.0/stravalib/tests/unit/test_client_utils.py000066400000000000000000000074461442076457100237460ustar00rootroot00000000000000import datetime import os from unittest import mock from urllib import parse as urlparse import pytz from stravalib.client import Client from stravalib.tests import RESOURCES_DIR, TestBase class ClientUtilsTest(TestBase): client = Client() def test_utc_datetime_to_epoch_utc_datetime_given_correct_epoch_returned( self, ): dt = pytz.utc.localize(datetime.datetime(2014, 1, 1, 0, 0, 0)) self.assertEqual(1388534400, self.client._utc_datetime_to_epoch(dt)) class ClientAuthorizationUrlTest(TestBase): client = Client() def get_url_param(self, url, key): """ >>> get_url_param("http://www.example.com/?key=1", "key") 1 """ return urlparse.parse_qs(urlparse.urlparse(url).query)[key][0] def test_incorrect_scope_raises(self): self.assertRaises( Exception, self.client.authorization_url, 1, "www.example.com", scope="wrong", ) self.assertRaises( Exception, self.client.authorization_url, 1, "www.example.com", scope=["wrong"], ) def test_correct_scope(self): url = self.client.authorization_url( 1, "www.example.com", scope="activity:write" ) self.assertEqual(self.get_url_param(url, "scope"), "activity:write") # Check also with two params url = self.client.authorization_url( 1, "www.example.com", scope=["activity:write", "activity:read_all"] ) self.assertEqual( self.get_url_param(url, "scope"), "activity:write,activity:read_all", ) def test_scope_may_be_list(self): url = self.client.authorization_url( 1, "www.example.com", scope=["activity:write", "activity:read_all"] ) self.assertEqual( self.get_url_param(url, "scope"), "activity:write,activity:read_all", ) def test_incorrect_approval_prompt_raises(self): self.assertRaises( Exception, self.client.authorization_url, 1, "www.example.com", approval_prompt="wrong", ) def test_state_param(self): url = self.client.authorization_url( 1, "www.example.com", state="my_state" ) self.assertEqual(self.get_url_param(url, "state"), "my_state") def test_params(self): url = self.client.authorization_url(1, "www.example.com") self.assertEqual(self.get_url_param(url, "client_id"), "1") self.assertEqual( self.get_url_param(url, "redirect_uri"), "www.example.com" ) self.assertEqual(self.get_url_param(url, "approval_prompt"), "auto") class TestClientUploadActivity(TestBase): client = Client() def test_upload_activity_file_with_different_types(self): """ Test uploading an activity with different activity_file object types. """ with mock.patch( "stravalib.protocol.ApiV3.post", return_value={} ), open(os.path.join(RESOURCES_DIR, "sample.tcx")) as fp: # test activity_file with type TextIOWrapper uploader = self.client.upload_activity(fp, data_type="tcx") self.assertTrue(uploader.is_processing) # test activity_file with type str uploader = self.client.upload_activity( fp.read(), data_type="tcx", activity_type="ride" ) self.assertTrue(uploader.is_processing) # test activity_file with type bytes uploader = self.client.upload_activity( fp.read().encode("utf-8"), data_type="tcx", activity_type="ride", ) self.assertTrue(uploader.is_processing) stravalib-1.3.0/stravalib/tests/unit/test_field_conversions.py000066400000000000000000000016341442076457100247540ustar00rootroot00000000000000import pytest import pytz from stravalib.field_conversions import ( enum_value, enum_values, optional_input, timezone, ) from stravalib.strava_model import ActivityType, SportType def test_optional_input(): @optional_input def foo(x): return x + 1 assert foo(1) == 2 assert foo(None) is None def test_enum_value(): assert enum_value(ActivityType(__root__="Run")) == "Run" def test_enum_values(): assert enum_values( [ActivityType(__root__="Run"), SportType(__root__="Ride")] ) == ["Run", "Ride"] @pytest.mark.parametrize( "arg,expected_value", ( ("Factory", None), ("(GMT+00:00) Factory", None), ("Europe/Amsterdam", pytz.timezone("Europe/Amsterdam")), ("(GMT+01:00) Europe/Amsterdam", pytz.timezone("Europe/Amsterdam")), ), ) def test_timezone(arg, expected_value): assert timezone(arg) == expected_value stravalib-1.3.0/stravalib/tests/unit/test_limiter.py000066400000000000000000000163201442076457100227040ustar00rootroot00000000000000import arrow from stravalib.tests import TestBase from stravalib.util.limiter import ( SleepingRateLimitRule, XRateLimitRule, get_rates_from_response_headers, get_seconds_until_next_day, get_seconds_until_next_quarter, ) test_response = { "Status": "404 Not Found", "X-Request-Id": "a1a4a4973962ffa7e0f18d7c485fe741", "Content-Encoding": "gzip", "Content-Length": "104", "Connection": "keep-alive", "X-RateLimit-Limit": "600,30000", "X-UA-Compatible": "IE=Edge,chrome=1", "Cache-Control": "no-cache, private", "Date": "Tue, 14 Nov 2017 11:29:15 GMT", "X-FRAME-OPTIONS": "DENY", "Content-Type": "application/json; charset=UTF-8", "X-RateLimit-Usage": "4,67", } test_response_no_rates = { "Status": "200 OK", "X-Request-Id": "d465159561420f6e0239dc24429a7cf3", "Content-Encoding": "gzip", "Content-Length": "371", "Connection": "keep-alive", "X-UA-Compatible": "IE=Edge,chrome=1", "Cache-Control": "max-age=0, private, must-revalidate", "Date": "Tue, 14 Nov 2017 13:19:31 GMT", "X-FRAME-OPTIONS": "DENY", "Content-Type": "application/json; charset=UTF-8", } class LimiterTest(TestBase): def test_get_rates_from_response_headers(self): """Should return namedtuple with rates""" request_rates = get_rates_from_response_headers(test_response) self.assertEqual(600, request_rates.short_limit) self.assertEqual(30000, request_rates.long_limit) self.assertEqual(4, request_rates.short_usage) self.assertEqual(67, request_rates.long_usage) def test_get_rates_from_response_headers_missing_rates(self): """Should return namedtuple with None values for rates in case of missing rates in headers""" self.assertIsNone( get_rates_from_response_headers(test_response_no_rates) ) def test_get_seconds_until_next_quarter(self): """Should return number of seconds to next quarter of an hour""" self.assertEqual( 59, get_seconds_until_next_quarter( arrow.get(2017, 11, 1, 17, 14, 0, 0) ), ) self.assertEqual( 59, get_seconds_until_next_quarter( arrow.get(2017, 11, 1, 17, 59, 0, 0) ), ) self.assertEqual( 0, get_seconds_until_next_quarter( arrow.get(2017, 11, 1, 17, 59, 59, 999999) ), ) self.assertEqual( 899, get_seconds_until_next_quarter( arrow.get(2017, 11, 1, 17, 0, 0, 1) ), ) def test_get_seconds_until_next_day(self): """Should return the number of seconds until next day""" self.assertEqual( 59, get_seconds_until_next_day(arrow.get(2017, 11, 1, 23, 59, 0, 0)), ) self.assertEqual( 86399, get_seconds_until_next_day(arrow.get(2017, 11, 1, 0, 0, 0, 0)), ) class XRateLimitRuleTest(TestBase): def test_rule_normal_response(self): rule = XRateLimitRule( { "short": { "usage": 0, "limit": 600, "time": (60 * 15), "lastExceeded": None, }, "long": { "usage": 0, "limit": 30000, "time": (60 * 60 * 24), "lastExceeded": None, }, } ) rule(test_response) self.assertEqual(4, rule.rate_limits["short"]["usage"]) self.assertEqual(67, rule.rate_limits["long"]["usage"]) def test_rule_missing_rates_response(self): rule = XRateLimitRule( { "short": { "usage": 0, "limit": 600, "time": (60 * 15), "lastExceeded": None, }, "long": { "usage": 0, "limit": 30000, "time": (60 * 60 * 24), "lastExceeded": None, }, } ) rule(test_response_no_rates) self.assertEqual(0, rule.rate_limits["short"]["usage"]) self.assertEqual(0, rule.rate_limits["long"]["usage"]) class SleepingRateLimitRuleTest(TestBase): def setUp(self): self.test_response = test_response.copy() def test_invalid_priority(self): """Should raise ValueError in case of invalid priority""" with self.assertRaises(ValueError): SleepingRateLimitRule(priority="foobar") def test_get_wait_time_high_priority(self): """Should never sleep/wait after high priority requests""" self.assertEqual( 0, SleepingRateLimitRule()._get_wait_time(42, 42, 60, 3600) ) def test_get_wait_time_medium_priority(self): """Should return number of seconds to next short limit divided by number of remaining requests for that period""" rule = SleepingRateLimitRule(priority="medium", short_limit=11) self.assertEqual(1, rule._get_wait_time(1, 1, 10, 1000)) self.assertEqual(0.5, rule._get_wait_time(1, 1, 5, 1000)) def test_get_wait_time_low_priority(self): """Should return number of seconds to next long limit divided by number of remaining requests for that period""" rule = SleepingRateLimitRule(priority="low", long_limit=11) self.assertEqual(1, rule._get_wait_time(1, 1, 1, 10)) self.assertEqual(0.5, rule._get_wait_time(1, 1, 1, 5)) def test_get_wait_time_limit_reached(self): """Should wait until end of period when limit is reached, regardless priority""" rule = SleepingRateLimitRule(short_limit=10, long_limit=100) self.assertEqual(42, rule._get_wait_time(10, 10, 42, 1000)) self.assertEqual(42, rule._get_wait_time(1234, 10, 42, 1000)) self.assertEqual(21, rule._get_wait_time(10, 100, 42, 21)) self.assertEqual(21, rule._get_wait_time(10, 1234, 42, 21)) def test_invocation_unchanged_limits(self): """Should not update limits if these don't change""" self.test_response["X-RateLimit-Usage"] = "0, 0" self.test_response["X-RateLimit-Limit"] = "10000, 1000000" rule = SleepingRateLimitRule() self.assertEqual(10000, rule.short_limit) self.assertEqual(1000000, rule.long_limit) rule(self.test_response) self.assertEqual(10000, rule.short_limit) self.assertEqual(1000000, rule.long_limit) def test_invocation_changed_limits(self): """Should update limits in case of changes, depending on limit enforcement""" self.test_response["X-RateLimit-Usage"] = "0, 0" self.test_response["X-RateLimit-Limit"] = "600, 30000" # without limit enforcement (default) rule = SleepingRateLimitRule() rule(self.test_response) self.assertEqual(600, rule.short_limit) self.assertEqual(30000, rule.long_limit) # with limit enforcement rule = SleepingRateLimitRule(force_limits=True) rule(self.test_response) self.assertEqual(10000, rule.short_limit) self.assertEqual(1000000, rule.long_limit) stravalib-1.3.0/stravalib/tests/unit/test_model.py000066400000000000000000000243361442076457100223450ustar00rootroot00000000000000from datetime import datetime, timedelta from typing import List, Optional import pint import pytest import pytz from pydantic import BaseModel from stravalib import model from stravalib import unithelper as uh from stravalib.model import ( Activity, ActivityLap, ActivityPhoto, ActivityTotals, BackwardCompatibilityMixin, BaseEffort, BoundClientEntity, Club, LatLon, Segment, SegmentExplorerResult, SubscriptionCallback, ) from stravalib.strava_model import LatLng from stravalib.tests import TestBase from stravalib.unithelper import Quantity, UnitConverter @pytest.mark.parametrize("model_class,attr,value", ((Club, "name", "foo"),)) class TestLegacyModelSerialization: def test_legacy_deserialize(self, model_class, attr, value): with pytest.warns(DeprecationWarning): model_obj = model_class.deserialize({attr: value}) assert getattr(model_obj, attr) == value def test_legacy_from_dict(self, model_class, attr, value): with pytest.warns(DeprecationWarning): model_obj = model_class() model_obj.from_dict({attr: value}) assert getattr(model_obj, attr) == value def test_legacy_to_dict(self, model_class, attr, value): with pytest.warns(DeprecationWarning): model_obj = model_class(**{attr: value}) model_dict_legacy = model_obj.to_dict() model_dict_modern = model_obj.dict() assert model_dict_legacy == model_dict_modern @pytest.mark.parametrize( "model_class,raw,expected_value", ( (Club, {"name": "foo"}, "foo"), (ActivityTotals, {"elapsed_time": 100}, timedelta(seconds=100)), ( ActivityTotals, {"distance": 100.0}, UnitConverter("meters")(100.0), ), ( Activity, {"timezone": "Europe/Amsterdam"}, pytz.timezone("Europe/Amsterdam"), ), (Club, {"activity_types": ["Run", "Ride"]}, ["Run", "Ride"]), (Activity, {"sport_type": "Run"}, "Run"), ), ) def test_backward_compatibility_mixin_field_conversions( model_class, raw, expected_value ): obj = model_class.parse_obj(raw) assert getattr(obj, list(raw.keys())[0]) == expected_value @pytest.mark.parametrize( "model_class,raw,expected_value", ( (Activity, {"start_latlng": "5.4,4.3"}, LatLon(__root__=[5.4, 4.3])), (Activity, {"start_latlng": []}, None), (Segment, {"start_latlng": []}, None), (SegmentExplorerResult, {"start_latlng": []}, None), (ActivityPhoto, {"location": []}, None), (Activity, {"timezone": "foobar"}, None), ( Activity, {"start_date_local": "2023-01-17T11:06:07Z"}, datetime(2023, 1, 17, 11, 6, 7), ), ( BaseEffort, {"start_date_local": "2023-01-17T11:06:07Z"}, datetime(2023, 1, 17, 11, 6, 7), ), ( ActivityLap, {"start_date_local": "2023-01-17T11:06:07Z"}, datetime(2023, 1, 17, 11, 6, 7), ), ), ) def test_deserialization_edge_cases(model_class, raw, expected_value): obj = model_class.parse_obj(raw) assert getattr(obj, list(raw.keys())[0]) == expected_value def test_subscription_callback_field_names(): sub_callback_raw = { "hub.mode": "subscribe", "hub.verify_token": "STRAVA", "hub.challenge": "15f7d1a91c1f40f8a748fd134752feb3", } sub_callback = SubscriptionCallback.parse_obj(sub_callback_raw) assert sub_callback.hub_mode == "subscribe" assert sub_callback.hub_verify_token == "STRAVA" # Below are some toy classes to test type extensions and attribute lookup: class A(BaseModel, BackwardCompatibilityMixin): x: Optional[int] = None class ConversionA(A, BackwardCompatibilityMixin): _field_conversions = {"x": uh.meters} class BoundA(A, BoundClientEntity): pass class B(BaseModel, BackwardCompatibilityMixin): a: Optional[A] = None bound_a: Optional[BoundA] = None class BoundB(B, BoundClientEntity): pass class C(BaseModel, BackwardCompatibilityMixin): a: Optional[List[A]] = None bound_a: Optional[List[BoundA]] = None class BoundC(C, BoundClientEntity): pass class D(BaseModel, BackwardCompatibilityMixin): a: Optional[LatLng] = None class ExtA(A): def foo(self): return self.x class ExtLatLng(LatLng): def foo(self): return f"[{self[0], self[1]}]" @pytest.mark.parametrize( "lookup_expression,expected_result,expected_bound_client", ( (A().x, None, False), (B().a, None, False), (A(x=1).x, 1, False), (B(a=A(x=1)).a, A(x=1), False), (C(a=[A(x=1), A(x=2)]).a[1], A(x=2), False), (ConversionA(x=1).x, pint.Quantity("1 meter"), False), (BoundA(x=1).x, 1, False), (B(bound_a=BoundA(x=1)).bound_a, BoundA(x=1), None), ( B(bound_a=BoundA(x=1, bound_client=1)).bound_a, BoundA(x=1, bound_client=1), True, ), (BoundB(a=A(x=1)).a, A(x=1), False), (BoundB(a=A(x=1), bound_client=1).a, A(x=1), False), (BoundB(bound_a=BoundA(x=1)).bound_a, BoundA(x=1), None), ( BoundB(bound_a=BoundA(x=1), bound_client=1).bound_a, BoundA(x=1, bound_client=1), True, ), (C(bound_a=[BoundA(x=1), BoundA(x=2)]).bound_a[1], BoundA(x=2), None), ( C( bound_a=[ BoundA(x=1, bound_client=1), BoundA(x=2, bound_client=1), ] ).bound_a[1], BoundA(x=2, bound_client=1), True, ), (BoundC(a=[A(x=1), A(x=2)]).a[1], A(x=2), False), (BoundC(a=[A(x=1), A(x=2)], bound_client=1).a[1], A(x=2), False), ( BoundC(bound_a=[BoundA(x=1), BoundA(x=2)]).bound_a[1], BoundA(x=2), None, ), ( BoundC(bound_a=[BoundA(x=1), BoundA(x=2)], bound_client=1).bound_a[ 1 ], BoundA(x=2, bound_client=1), True, ), ), ) def test_backward_compatible_attribute_lookup( lookup_expression, expected_result, expected_bound_client ): assert lookup_expression == expected_result if expected_bound_client: assert lookup_expression.bound_client is not None elif expected_bound_client is None: assert lookup_expression.bound_client is None elif not expected_bound_client: assert not hasattr(lookup_expression, "bound_client") class ModelTest(TestBase): def setUp(self): super(ModelTest, self).setUp() def test_entity_collections(self): a = model.Athlete() d = { "clubs": [ {"resource_state": 2, "id": 7, "name": "Team Roaring Mouse"}, {"resource_state": 2, "id": 1, "name": "Team Strava Cycling"}, { "resource_state": 2, "id": 34444, "name": "Team Strava Cyclocross", }, ] } a.from_dict(d) self.assertEqual(3, len(a.clubs)) self.assertEqual("Team Roaring Mouse", a.clubs[0].name) def test_speed_units(self): a = model.Activity() a.max_speed = 1000 # m/s a.average_speed = 1000 # m/s self.assertAlmostEqual(3600.0, float(uh.kph(a.max_speed))) self.assertAlmostEqual(3600.0, float(uh.kph(a.average_speed))) a.max_speed = uh.mph(1.0) # print repr(a.max_speed) self.assertAlmostEqual(1.61, float(uh.kph(a.max_speed)), places=2) def test_time_intervals(self): segment = model.Segment() # s.pr_time = XXXX split = model.Split() split.moving_time = 3.1 split.elapsed_time = 5.73 def test_distance_units(self): # Gear g = model.Gear() g.distance = 1000 self.assertEqual(1.0, float(uh.kilometers(g.distance))) # Metric Split split = model.Split() split.distance = 1000 # meters split.elevation_difference = 1000 # meters self.assertIsInstance(split.distance, Quantity) self.assertIsInstance(split.elevation_difference, Quantity) self.assertEqual(1.0, float(uh.kilometers(split.distance))) self.assertEqual(1.0, float(uh.kilometers(split.elevation_difference))) split = None # Segment s = model.Segment() s.distance = 1000 s.elevation_high = 2000 s.elevation_low = 1000 self.assertIsInstance(s.distance, Quantity) self.assertIsInstance(s.elevation_high, Quantity) self.assertIsInstance(s.elevation_low, Quantity) self.assertEqual(1.0, float(uh.kilometers(s.distance))) self.assertEqual(2.0, float(uh.kilometers(s.elevation_high))) self.assertEqual(1.0, float(uh.kilometers(s.elevation_low))) # Activity a = model.Activity() a.distance = 1000 # m a.total_elevation_gain = 1000 # m self.assertIsInstance(a.distance, Quantity) self.assertIsInstance(a.total_elevation_gain, Quantity) self.assertEqual(1.0, float(uh.kilometers(a.distance))) self.assertEqual(1.0, float(uh.kilometers(a.total_elevation_gain))) def test_weight_units(self): """ """ # PowerActivityZone def test_subscription_deser(self): d = { "id": 1, "object_type": "activity", "aspect_type": "create", "callback_url": "http://you.com/callback/", "created_at": "2015-04-29T18:11:09.400558047-07:00", "updated_at": "2015-04-29T18:11:09.400558047-07:00", } sub = model.Subscription.parse_obj(d) self.assertEqual(d["id"], sub.id) def test_subscription_update_deser(self): d = { "subscription_id": "1", "owner_id": 13408, "object_id": 12312312312, "object_type": "activity", "aspect_type": "create", "event_time": 1297286541, } subupd = model.SubscriptionUpdate.deserialize(d) self.assertEqual( "2011-02-09 21:22:21", subupd.event_time.strftime("%Y-%m-%d %H:%M:%S"), ) stravalib-1.3.0/stravalib/tests/unit/test_unithelper.py000066400000000000000000000044551442076457100234240ustar00rootroot00000000000000from dataclasses import dataclass import pint import pytest from pint import Unit from stravalib import unithelper as uh from stravalib.unithelper import UnitsQuantity @dataclass class UnitsLikeQuantity(UnitsQuantity): num: float unit: str @pytest.mark.parametrize( "from_unit,to_unit,expected_magnitude,expected_unit", ( (None, "meter", 1, "meter"), ("meter", "meter", 1, "meter"), ("km", "meter", 1000, "meter"), ("m/s", "kph", 3.6, "kilometer / hour"), ), ) class TestUnitConversion: def test_conversion_legacy( self, from_unit, to_unit, expected_magnitude, expected_unit ): quantity = UnitsLikeQuantity(1, from_unit) if from_unit else 1 with pytest.warns(DeprecationWarning): converted_quantity = getattr(uh, to_unit)(quantity) assert converted_quantity.num == pytest.approx( expected_magnitude, 0.01 ) assert str(converted_quantity.unit) == expected_unit def test_conversion( self, from_unit, to_unit, expected_magnitude, expected_unit ): quantity = 1 * Unit(from_unit) if from_unit else 1 converted_quantity = getattr(uh, to_unit)(quantity) assert converted_quantity.magnitude == pytest.approx( expected_magnitude, 0.01 ) assert str(converted_quantity.units) == expected_unit @pytest.mark.parametrize( "obj,expected_result,expected_warning", ( (pint.Quantity("m"), True, None), (uh.Quantity(pint.Quantity("m")), True, None), (uh.meters(1), True, None), (UnitsLikeQuantity(1, "meter"), True, DeprecationWarning), (42, False, None), ), ) def test_is_quantity_type(obj, expected_result, expected_warning): if expected_warning: with pytest.warns(expected_warning): assert uh.is_quantity_type(obj) == expected_result else: assert uh.is_quantity_type(obj) == expected_result def test_legacy_accessors(): assert uh.meters(10).num == 10 assert uh.meters(10).unit == "meter" def test_arithmetic_comparison_support(): assert int(uh.meters(2.1)) == 2 assert float(uh.meters(2.1)) == 2.1 assert uh.meters(2) == uh.meters(2) assert uh.meters(2) > uh.meters(1) assert uh.meters(2) + uh.meters(1) == uh.meters(3) stravalib-1.3.0/stravalib/unit_registry.py000066400000000000000000000001101442076457100207540ustar00rootroot00000000000000from pint import UnitRegistry ureg = UnitRegistry() Q_ = ureg.Quantity stravalib-1.3.0/stravalib/unithelper.py000066400000000000000000000051731442076457100202420ustar00rootroot00000000000000""" Unit Helper ============== Helpers for converting Strava's units to something more practical. """ from numbers import Number from typing import Any, Protocol, Union, runtime_checkable import pint from stravalib.exc import warn_units_deprecated from stravalib.unit_registry import Q_ @runtime_checkable class UnitsQuantity(Protocol): """ A type that represents the (deprecated) `units` Quantity. The `unit` attribute in the units library consists of other classes, so this representation may not be 100% backward compatible! """ num: float unit: str class Quantity(Q_): """ Extension of `pint.Quantity` for temporary backward compatibility with the legacy `units` package. """ @property def num(self): warn_units_deprecated() return self.magnitude @property def unit(self): warn_units_deprecated() return str(self.units) def __int__(self): return int(self.magnitude) def __float__(self): return float(self.magnitude) class UnitConverter: def __init__(self, unit: str): self.unit = unit def __call__(self, q: Union[Number, pint.Quantity, UnitsQuantity]): if isinstance(q, Number): # provided quantity is unitless, so mimick legacy `units` behavior: converted_q = Quantity(q, self.unit) else: try: converted_q = q.to(self.unit) except AttributeError: # unexpected type of quantity, maybe it's a legacy `units` Quantity warn_units_deprecated() converted_q = Quantity(q.num, q.unit).to(self.unit) return converted_q def is_quantity_type(obj: Any): if isinstance(obj, (pint.Quantity, Quantity)): return True elif isinstance(obj, UnitsQuantity): # check using Duck Typing warn_units_deprecated() return True else: return False meter = meters = UnitConverter("m") second = seconds = UnitConverter("s") hour = hours = UnitConverter("hour") foot = feet = UnitConverter("ft") mile = miles = UnitConverter("mi") kilometer = kilometers = UnitConverter("km") meters_per_second = UnitConverter("m/s") miles_per_hour = mph = UnitConverter("mi/hour") kilometers_per_hour = kph = UnitConverter("km/hour") kilogram = kilograms = kg = kgs = UnitConverter("kg") pound = pounds = lb = lbs = UnitConverter("lb") def c2f(celsius): """ Convert Celsius to Fahrenheit. Parameters ---------- celsius : Temperature in Celsius. Returns ------- float Temperature in Fahrenheit. """ return (9.0 / 5.0) * celsius + 32 stravalib-1.3.0/stravalib/util/000077500000000000000000000000001442076457100164605ustar00rootroot00000000000000stravalib-1.3.0/stravalib/util/__init__.py000066400000000000000000000000001442076457100205570ustar00rootroot00000000000000stravalib-1.3.0/stravalib/util/limiter.py000066400000000000000000000331021442076457100204760ustar00rootroot00000000000000""" Utilities ============== Rate limiter classes. These are basically callables that when called register that a request was issued. Depending on how they are configured that may cause a pause or exception if a rate limit has been exceeded. Obviously it is up to the calling code to ensure that these callables are invoked with every (successful?) call to the backend API. (There is probably a better way to hook these into the requests library directly ... TBD.) From the Strava docs: Strava API usage is limited on a per-application basis using a short term, 15 minute, limit and a long term, daily, limit. The default rate limit allows 600 requests every 15 minutes, with up to 30,000 requests per day. This limit allows applications to make 40 requests per minute for about half the day. """ import collections import logging import time from datetime import datetime, timedelta import arrow from stravalib import exc def total_seconds(td): """Alternative to datetime.timedelta.total_seconds total_seconds() only available since Python 2.7 https://docs.python.org/2/library/datetime.html#datetime.timedelta.total_seconds """ return ( td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6 ) / 10**6 RequestRate = collections.namedtuple( "RequestRate", ["short_usage", "long_usage", "short_limit", "long_limit"] ) def get_rates_from_response_headers(headers): """ Returns a namedtuple with values for short - and long usage and limit rates found in provided HTTP response headers :param headers: HTTP response headers :type headers: dict :return: namedtuple with request rates or None if no rate-limit headers present in response. :rtype: Optional[RequestRate] """ try: usage_rates = [int(v) for v in headers["X-RateLimit-Usage"].split(",")] limit_rates = [int(v) for v in headers["X-RateLimit-Limit"].split(",")] return RequestRate( short_usage=usage_rates[0], long_usage=usage_rates[1], short_limit=limit_rates[0], long_limit=limit_rates[1], ) except KeyError: return None def get_seconds_until_next_quarter(now=None): """ Returns the number of seconds until the next quarter of an hour. This is the short-term rate limit used by Strava. :param now: A (utc) timestamp :type now: arrow.arrow.Arrow :return: the number of seconds until the next quarter, as int """ if now is None: now = arrow.utcnow() return ( 899 - ( now - now.replace( minute=(now.minute // 15) * 15, second=0, microsecond=0 ) ).seconds ) def get_seconds_until_next_day(now=None): """ Returns the number of seconds until the next day (utc midnight). This is the long-term rate limit used by Strava. :param now: A (utc) timestamp :type now: arrow.arrow.Arrow :return: the number of seconds until next day, as int """ if now is None: now = arrow.utcnow() return (now.ceil("day") - now).seconds class XRateLimitRule(object): def __init__(self, limits, force_limits=False): """ :param limits: THe limits structure. :param force_limits: If False (default), this rule will set/update its limits based on what the Strava API tells it. If True, the provided limits will be enforced, i.e. ignoring the limits given by the API. """ self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) self.rate_limits = limits # should limit args be validated? self.limit_time_invalid = 0 self.force_limits = force_limits @property def limit_timeout(self): return self.limit_time_invalid def __call__(self, response_headers): self._update_usage(response_headers) for limit in self.rate_limits.values(): self._check_limit_time_invalid(limit) self._check_limit_rates(limit) def _update_usage(self, response_headers): rates = get_rates_from_response_headers(response_headers) if rates: self.log.debug( "Updating rate-limit limits and usage from headers: {}".format( rates ) ) self.rate_limits["short"]["usage"] = rates.short_usage self.rate_limits["long"]["usage"] = rates.long_usage if not self.force_limits: self.rate_limits["short"]["limit"] = rates.short_limit self.rate_limits["long"]["limit"] = rates.long_limit def _check_limit_rates(self, limit): if limit["usage"] >= limit["limit"]: self.log.debug("Rate limit of {0} reached.".format(limit["limit"])) limit["lastExceeded"] = datetime.now() self._raise_rate_limit_exception(limit["limit"], limit["time"]) def _check_limit_time_invalid(self, limit): self.limit_time_invalid = 0 if limit["lastExceeded"] is not None: delta = (datetime.now() - limit["lastExceeded"]).total_seconds() if delta < limit["time"]: self.limit_time_invalid = limit["time"] - delta self.log.debug( "Rate limit invalid duration {0} seconds.".format( self.limit_time_invalid ) ) self._raise_rate_limit_timeout( self.limit_timeout, limit["limit"] ) def _raise_rate_limit_exception(self, timeout, limit_rate): raise exc.RateLimitExceeded( "Rate limit of {0} exceeded. " "Try again in {1} seconds.".format(limit_rate, timeout), limit=limit_rate, timeout=timeout, ) def _raise_rate_limit_timeout(self, timeout, limit_rate): raise exc.RateLimitTimeout( "Rate limit of {0} exceeded. " "Try again in {1} seconds.".format(limit_rate, timeout), limit=limit_rate, timeout=timeout, ) class SleepingRateLimitRule(object): """ A rate limit rule that can be prioritized and can dynamically adapt its limits based on API responses. Given its priority, it will enforce a variable "cool-down" period after each response. When rate limits are reached within their period, this limiter will wait until the end of that period. It will NOT raise any kind of exception in this case. """ def __init__( self, priority="high", short_limit=10000, long_limit=1000000, force_limits=False, ): """ Constructs a new SleepingRateLimitRule. :param priority: The priority for this rule. When 'low', the cool-down period after each request will be such that the long-term limits will not be exceeded. When 'medium', the cool-down period will be such that the short-term limits will not be exceeded. When 'high', there will be no cool-down period. :type priority: str :param short_limit: (Optional) explicit short-term limit :type short_limit: int :param long_limit: (Optional) explicit long-term limit :type long_limit: int :param force_limits: If False (default), this rule will set/update its limits based on what the Strava API tells it. If True, the provided limits will be enforced, i.e. ignoring the limits given by the API. """ if priority not in ["low", "medium", "high"]: raise ValueError( 'Invalid priority "{0}", expecting one of "low", "medium" or "high"'.format( priority ) ) self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) self.priority = priority self.short_limit = short_limit self.long_limit = long_limit self.force_limits = force_limits def _get_wait_time( self, short_usage, long_usage, seconds_until_short_limit, seconds_until_long_limit, ): if long_usage >= self.long_limit: self.log.warning("Long term API rate limit exceeded") return seconds_until_long_limit elif short_usage >= self.short_limit: self.log.warning("Short term API rate limit exceeded") return seconds_until_short_limit if self.priority == "high": return 0 elif self.priority == "medium": return seconds_until_short_limit / (self.short_limit - short_usage) elif self.priority == "low": return seconds_until_long_limit / (self.long_limit - long_usage) def __call__(self, response_headers): rates = get_rates_from_response_headers(response_headers) if rates: time.sleep( self._get_wait_time( rates.short_usage, rates.long_usage, get_seconds_until_next_quarter(), get_seconds_until_next_day(), ) ) if not self.force_limits: self.short_limit = rates.short_limit self.long_limit = rates.long_limit class RateLimitRule(object): def __init__(self, requests, seconds, raise_exc=False): """ :param requests: Number of requests for limit. :param seconds: The number of seconds for that number of requests (may be float) :param raise_exc: Whether to raise an exception when limit is reached (as opposed to pausing) """ self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) self.timeframe = timedelta(seconds=seconds) self.requests = requests self.tab = collections.deque(maxlen=self.requests) self.raise_exc = raise_exc def __call__(self, args): """ Register another request is being issued. Depending on configuration of the rule will pause if rate limit has been reached, or raise exception, etc. """ # First check if the deque is full; that indicates that we'd better check whether # we need to pause. if len(self.tab) == self.requests: # Grab the oldest (leftmost) timestamp and check to see if it is greater than 1 second delta = datetime.now() - self.tab[0] if ( delta < self.timeframe ): # Has it been less than configured timeframe since oldest request? if self.raise_exc: raise exc.RateLimitExceeded( "Rate limit exceeded (can try again in {0})".format( self.timeframe - delta ) ) else: # Wait the difference between timeframe and the oldest request. td = self.timeframe - delta sleeptime = ( hasattr(td, "total_seconds") and td.total_seconds() or total_seconds(td) ) self.log.debug( "Rate limit triggered; sleeping for {0}".format( sleeptime ) ) time.sleep(sleeptime) self.tab.append(datetime.now()) class RateLimiter(object): def __init__(self): self.log = logging.getLogger( "{0.__module__}.{0.__name__}".format(self.__class__) ) self.rules = [] def __call__(self, args): """ Register another request is being issued. """ for r in self.rules: r(args) class DefaultRateLimiter(RateLimiter): """ Implements something similar to the default rate limit for Strava apps. To do this correctly we would actually need to change our logic to reset the limit at midnight, etc. Will make this more complex in the future. Strava API usage is limited on a per-application basis using a short term, 15 minute, limit and a long term, daily, limit. The default rate limit allows 600 requests every 15 minutes, with up to 30,000 requests per day. """ def __init__(self): """ Strava API usage is limited on a per-application basis using a short term, 15 minute, limit and a long term, daily, limit. The default rate limit allows 600 requests every 15 minutes, with up to 30,000 requests per day. This limit allows applications to make 40 requests per minute for about half the day. """ super(DefaultRateLimiter, self).__init__() self.rules.append( XRateLimitRule( { "short": { "usageFieldIndex": 0, "usage": 0, # 60s * 15 = 15 min "limit": 600, "time": (60 * 15), "lastExceeded": None, }, "long": { "usageFieldIndex": 1, "usage": 0, # 60s * 60m * 24 = 1 day "limit": 30000, "time": (60 * 60 * 24), "lastExceeded": None, }, } ) ) # XRateLimitRule used instead of timer based RateLimitRule # self.rules.append(RateLimitRule(requests=40, seconds=60, raise_exc=False)) # self.rules.append(RateLimitRule(requests=30000, seconds=(3600 * 24), raise_exc=True))