diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..e95e611e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Copyright 2022 Google LLC +# +# 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. + +# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners + +.github/ @googlemaps/admin diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..4782add5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,50 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'type: bug, triage me' +assignees: '' + +--- + +Thanks for stopping by to let us know something could be better! + +--- +**PLEASE READ** + +If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/). This will ensure a timely response. + +Discover additional support services for the Google Maps Platform, including developer communities, technical guidance, and expert support at the Google Maps Platform [support resources page](https://developers.google.com/maps/support/). + +If your bug or feature request is not related to this particular library, please visit the Google Maps Platform [issue trackers](https://developers.google.com/maps/support/#issue_tracker). + +Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. + +--- + +Please be sure to include as much information as possible: + +#### Environment details + +1. Specify the API at the beginning of the title (for example, "Places: ...") +2. OS type and version +3. Library version and other environment information + +#### Steps to reproduce + + 1. ? + +#### Code example + +```python +# example +``` + +#### Stack trace +``` +# example +``` + +Following these steps will guarantee the quickest resolution possible. + +Thanks! diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..39c3c5ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +--- +name: Feature request +about: Suggest an idea for this library +title: '' +labels: 'type: feature request, triage me' +assignees: '' + +--- + +Thanks for stopping by to let us know something could be better! + +--- +**PLEASE READ** + +If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/). This will ensure a timely response. + +Discover additional support services for the Google Maps Platform, including developer communities, technical guidance, and expert support at the Google Maps Platform [support resources page](https://developers.google.com/maps/support/). + +If your bug or feature request is not related to this particular library, please visit the Google Maps Platform [issue trackers](https://developers.google.com/maps/support/#issue_tracker). + +Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. + +--- + + **Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + + **Describe the solution you'd like** +A clear and concise description of what you want to happen. + + **Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + + **Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/support_request.md b/.github/ISSUE_TEMPLATE/support_request.md new file mode 100644 index 00000000..495a5c99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support_request.md @@ -0,0 +1,21 @@ +--- +name: Support request +about: If you have a support contract with Google, please create an issue in the Google + Cloud Support console. +title: '' +labels: 'triage me, type: question' +assignees: '' + +--- + +**PLEASE READ** + +If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/). This will ensure a timely response. + +Discover additional support services for the Google Maps Platform, including developer communities, technical guidance, and expert support at the Google Maps Platform [support resources page](https://developers.google.com/maps/support/). + +If your bug or feature request is not related to this particular library, please visit the Google Maps Platform [issue trackers](https://developers.google.com/maps/support/#issue_tracker). + +Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. + +--- diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 00000000..4d7d59e9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,12 @@ +--- +name: Pull request +about: Create a pull request +label: 'triage me' +--- +Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: +- [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea +- [ ] Ensure the tests and linter pass +- [ ] Code coverage does not decrease (if any source code was changed) +- [ ] Appropriate docs were updated (if necessary) + +Fixes # 🦕 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..36f9436b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# Copyright 2022 Google LLC +# +# 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. + +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..009707d4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +Thank you for opening a Pull Request! + +--- + +Before submitting your PR, there are a few things you can do to make sure it goes smoothly: +- [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea +- [ ] Ensure the tests and linter pass +- [ ] Code coverage does not decrease (if any source code was changed) +- [ ] Appropriate docs were updated (if necessary) + +Fixes # 🦕 diff --git a/.github/scripts/distribution.sh b/.github/scripts/distribution.sh new file mode 100755 index 00000000..e779cd1d --- /dev/null +++ b/.github/scripts/distribution.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Copyright 2022 Google LLC +# +# 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. + + +rm -rf dist + +python setup.py sdist +pip install $(find dist -name googlemaps-*.tar.gz) diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh new file mode 100755 index 00000000..39e7f9f0 --- /dev/null +++ b/.github/scripts/install.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Copyright 2022 Google LLC +# +# 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. + + +set -exo pipefail + +if ! python3 -m pip --version; then + curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py + sudo python3 get-pip.py + sudo python3 -m pip install --upgrade setuptools + sudo python3 -m pip install nox twine +else + sudo python3 -m pip install --upgrade setuptools + python3 -m pip install nox + python3 -m pip install --prefer-binary twine +fi diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000..1d39e65d --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,74 @@ +# Copyright 2022 Google LLC +# +# 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. + +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 120 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 180 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - pinned + - "type: bug" + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: false + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: false + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: false + +# Label to use when marking as stale +staleLabel: "stale" + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. Please comment here if it is still valid so that we can + reprioritize. Thank you! + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +closeComment: > + Closing this. Please reopen if you believe it should be addressed. Thank you for your contribution. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 10 + +# Limit to only `issues` or `pulls` +only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +# pulls: +# daysUntilStale: 30 +# markComment: > +# This pull request has been automatically marked as stale because it has not had +# recent activity. It will be closed if no further activity occurs. Thank you +# for your contributions. + +# issues: +# exemptLabels: +# - confirmed diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml new file mode 100644 index 00000000..a7b2d39c --- /dev/null +++ b/.github/sync-repo-settings.yaml @@ -0,0 +1,44 @@ +# Copyright 2022 Google LLC +# +# 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. + +# https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings + +rebaseMergeAllowed: true +squashMergeAllowed: true +mergeCommitAllowed: false +deleteBranchOnMerge: true +branchProtectionRules: +- pattern: main + isAdminEnforced: false + requiresStrictStatusChecks: false + requiredStatusCheckContexts: + - 'cla/google' + - 'test' + - 'snippet-bot check' + - 'header-check' + requiredApprovingReviewCount: 1 + requiresCodeOwnerReviews: true +- pattern: master + isAdminEnforced: false + requiresStrictStatusChecks: false + requiredStatusCheckContexts: + - 'cla/google' + - 'test' + - 'snippet-bot check' + - 'header-check' + requiredApprovingReviewCount: 1 + requiresCodeOwnerReviews: true +permissionRules: + - team: admin + permission: admin diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 00000000..6b46ebbb --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,32 @@ +# Copyright 2022 Google LLC +# +# 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. + +name: Dependabot +on: pull_request + +permissions: + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.SYNCED_GITHUB_TOKEN_REPO}} + steps: + - name: approve + run: gh pr review --approve "$PR_URL" + - name: merge + run: gh pr merge --auto --squash --delete-branch "$PR_URL" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..c83d6c38 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,63 @@ +# Copyright 2023 Google LLC +# +# 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. + +# A workflow that pushes artifacts to Sonatype +name: Publish + +on: + push: + tags: + - '*' + repository_dispatch: + types: [docs] + +jobs: + docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Python + uses: "actions/setup-python@v3" + with: + python-version: "3.9" + + - name: Install dependencies + run: ./.github/scripts/install.sh + + - name: Generate docs + run: python3 -m nox --session docs + + - name: Update gh-pages branch with docs + run: | + echo "Creating tar for generated docs" + cd ./docs/_build/html && tar cvf ~/docs.tar . + + echo "Unpacking tar into gh-pages branch" + git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* + cd $GITHUB_WORKSPACE && git checkout gh-pages && tar xvf ~/docs.tar + + - name: PR Changes + uses: peter-evans/create-pull-request@v4 + with: + token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} + commit-message: 'docs: Update docs' + committer: googlemaps-bot + author: googlemaps-bot + title: 'docs: Update docs' + body: | + Updated GitHub pages with latest from `python3 -m nox --session docs`. + branch: googlemaps-bot/update_gh_pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a0a28dce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +# Copyright 2020 Google LLC +# +# 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. + +name: Release +on: + push: + branches: [ master ] +jobs: + release: + runs-on: ubuntu-latest + env: + PYTHONDONTWRITEBYTECODE: 1 + steps: + - name: Setup Python + uses: "actions/setup-python@v3" + with: + python-version: "3.9" + - name: Checkout + uses: actions/checkout@v3 + with: + token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} + - name: Install dependencies + run: ./.github/scripts/install.sh + - name: Run distribution + run: python3 -m nox -e distribution + - name: Cleanup old dist + run: rm -rf googlemaps-* dist/ + - name: Semantic Release + uses: cycjimmy/semantic-release-action@v3 + with: + semantic_version: 19 + extra_plugins: | + "@semantic-release/commit-analyzer" + "@semantic-release/release-notes-generator" + "@google/semantic-release-replace-plugin" + "@semantic-release/exec" + "@semantic-release/git" + "@semantic-release/github" + env: + GH_TOKEN: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..570a087a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +# Copyright 2023 Google LLC +# +# 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. + +# A workflow that runs tests on every new pull request +name: Run tests + +on: + repository_dispatch: + types: [test] + pull_request: + branches: ['*'] + push: + branches: ['*'] + +jobs: + matrix: + name: "Run tests on Python ${{ matrix.python-version }}" + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: "actions/setup-python@v3" + with: + python-version: "${{ matrix.python-version }}" + + - name: Install dependencies + run: ./.github/scripts/install.sh + + - name: Run tests + run: | + python3 -m nox --session "tests-${{ matrix.python-version }}" + python3 -m nox -e distribution + test: + name: Wait for matrix to finish + needs: [matrix] + runs-on: ubuntu-latest + steps: + - run: | + echo "Test matrix finished"; + exit 0; diff --git a/.gitignore b/.gitignore index c115db24..93e7a2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Compiled source # ################### *.pyc - + # OS generated files # ###################### .DS_Store @@ -15,16 +15,23 @@ Thumbs.db # Sphinx documentation # ######################## docs/_build/ +generated_docs/ +# Release generated files # +########################### +.eggs/ +build/ +dist/ # vim *.swp # python testing things etc .coverage -.tox +.nox env -nosetests.xml googlemaps.egg-info *.egg - +.vscode/ +.idea/ +index.py diff --git a/.releaserc b/.releaserc new file mode 100644 index 00000000..5f53e6f5 --- /dev/null +++ b/.releaserc @@ -0,0 +1,23 @@ +branches: + - master +plugins: + - "@semantic-release/commit-analyzer" + - "@semantic-release/release-notes-generator" + - - "@google/semantic-release-replace-plugin" + - replacements: + - files: + - "./googlemaps/__init__.py" + from: "__version__ = \".*\"" + to: "__version__ = \"${nextRelease.version}\"" + - files: + - "./setup.py" + from: 'version=".*"' + to: 'version="${nextRelease.version}"' + - [ "@semantic-release/exec", { publishCmd: "python3 setup.py sdist && python3 -m twine upload dist/*" }] + - - "@semantic-release/git" + - assets: + - "./googlemaps/__init__.py" + - "./setup.py" + - "@semantic-release/github" +options: + debug: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cdd7714b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: python -python: 2.7 - -install: - - pip install requests - - pip install tox - -script: - - tox - -env: - - TOXENV=py27 - - TOXENV=py32 - - TOXENV=py34 - - TOXENV=docs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..35deb20a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog +All notable changes to this project will be documented in this file. + +## [v4.2.0] +### Added +- Add support for Maps Static API (#344) + +## [v4.1.0] +### Added +- Adding support for passing in `experience_id` to Client class (#338) + +## [v4.0.0] +### Changed +- Python 2 is no longer supported +- Removed place fields: `alt_id`, `id`, `reference`, and `scope`. Read more about this at https://developers.google.com/maps/deprecations. + +## [v3.1.4] +### Changed +- `APIError.__str__` should always return a str (#328) + +## [v3.1.3] +### Changed +- deprecation warning for place fields: `alt_id`, `id`, `reference`, and `scope`. Read more about this at https://developers.google.com/maps/deprecations. + +## [v3.1.2] +### Added +- Tests for distribution tar as part of CI +- Support for subfields such as `geometry/location` and `geometry/viewport` in Places. + +## [v3.1.1] +### Changed +- Added changelog to manifest + +## [v3.1.0] +### Changed +- Switched build system to use [nox](https://nox.thea.codes/en/stable/), pytest, and codecov. Added Python 3.7 to test framework. +- Set precision of truncated latitude and longitude floats [to 8 decimals](https://github.com/googlemaps/google-maps-services-python/pull/301) instead of 6. +- Minimum version of requests increased. +- Session token parameter [added](https://github.com/googlemaps/google-maps-services-python/pull/244) to `place()`. +- Fixed issue where headers in `request_kwargs` were being overridden. +### Added +- Automation for PyPi uploads. +- Long description to package. +- Added tests to manifest and tarball. +### Removed +- Removed places `places_autocomplete_session_token` which can be replaced with `uuid.uuid4().hex`. +- Removed deprecated `places_radar`. + + +**Note:** Start of changelog is 2019-08-27, [v3.0.2]. + +[Unreleased]: https://github.com/googlemaps/google-maps-services-python/compare/4.2.0...HEAD +[v4.2.0]: https://github.com/googlemaps/google-maps-services-python/compare/4.1.0...4.2.0 +[v4.1.0]: https://github.com/googlemaps/google-maps-services-python/compare/4.0.0...4.1.0 +[v4.0.0]: https://github.com/googlemaps/google-maps-services-python/compare/3.1.4...4.0.0 +[v3.1.4]: https://github.com/googlemaps/google-maps-services-python/compare/3.1.3...3.1.4 +[v3.1.3]: https://github.com/googlemaps/google-maps-services-python/compare/3.1.2...3.1.3 +[v3.1.2]: https://github.com/googlemaps/google-maps-services-python/compare/3.1.1...3.1.2 +[v3.1.1]: https://github.com/googlemaps/google-maps-services-python/compare/3.1.0...3.1.1 +[v3.1.0]: https://github.com/googlemaps/google-maps-services-python/compare/3.0.2...3.1.0 +[v3.0.2]: https://github.com/googlemaps/google-maps-services-python/compare/3.0.1...3.0.2 +[v3.0.1]: https://github.com/googlemaps/google-maps-services-python/compare/3.0.0...3.0.1 +[v3.0.0]: https://github.com/googlemaps/google-maps-services-python/compare/2.5.1...3.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f8b12cb5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,63 @@ +# Google Open Source Community Guidelines + +At Google, we recognize and celebrate the creativity and collaboration of open +source contributors and the diversity of skills, experiences, cultures, and +opinions they bring to the projects and communities they participate in. + +Every one of Google's open source projects and communities are inclusive +environments, based on treating all individuals respectfully, regardless of +gender identity and expression, sexual orientation, disabilities, +neurodiversity, physical appearance, body size, ethnicity, nationality, race, +age, religion, or similar personal characteristic. + +We value diverse opinions, but we value respectful behavior more. + +Respectful behavior includes: + +* Being considerate, kind, constructive, and helpful. +* Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or + physically threatening behavior, speech, and imagery. +* Not engaging in unwanted physical contact. + +Some Google open source projects [may adopt][] an explicit project code of +conduct, which may have additional detailed expectations for participants. Most +of those projects will use our [modified Contributor Covenant][]. + +[may adopt]: https://opensource.google/docs/releasing/preparing/#conduct +[modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/ + +## Resolve peacefully + +We do not believe that all conflict is necessarily bad; healthy debate and +disagreement often yields positive results. However, it is never okay to be +disrespectful. + +If you see someone behaving disrespectfully, you are encouraged to address the +behavior directly with those involved. Many issues can be resolved quickly and +easily, and this gives people more control over the outcome of their dispute. +If you are unable to resolve the matter for any reason, or if the behavior is +threatening or harassing, report it. We are dedicated to providing an +environment where participants feel welcome and safe. + +## Reporting problems + +Some Google open source projects may adopt a project-specific code of conduct. +In those cases, a Google employee will be identified as the Project Steward, +who will receive and handle reports of code of conduct violations. In the event +that a project hasn’t identified a Project Steward, you can report problems by +emailing opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is +taken. The identity of the reporter will be omitted from the details of the +report supplied to the accused. In potentially harmful situations, such as +ongoing harassment or threats to anyone's safety, we may take action without +notice. + +*This document was adapted from the [IndieWeb Code of Conduct][] and can also +be found at .* + +[IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 42728d64..10a36b0f 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -12,4 +12,5 @@ Dave Holmes Luke Mahe Mark McDonald Sam Thorogood +Sean Wohltman Stephen McDonald diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b248a6b8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include CHANGELOG.md LICENSE README.md +global-exclude __pycache__ +global-exclude *.py[co] diff --git a/README.md b/README.md index f0f65cc7..40823790 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,32 @@ Python Client for Google Maps Services ==================================== +![Test](https://github.com/googlemaps/google-maps-services-js/workflows/Test/badge.svg) +![Release](https://github.com/googlemaps/google-maps-services-js/workflows/Release/badge.svg) +[![codecov](https://codecov.io/gh/googlemaps/google-maps-services-python/branch/master/graph/badge.svg)](https://codecov.io/gh/googlemaps/google-maps-services-python) +[![PyPI version](https://badge.fury.io/py/googlemaps.svg)](https://badge.fury.io/py/googlemaps) +![PyPI - Downloads](https://img.shields.io/pypi/dd/googlemaps) +![GitHub contributors](https://img.shields.io/github/contributors/googlemaps/google-maps-services-python) + ## Description -Use Python? Want to [geocode][Geocoding API] something? Looking for [directions][Directions API]? -Maybe [matrices of directions][Distance Matrix API]? This library brings the [Google Maps API Web -Services] to your Python application. -![Analytics](https://ga-beacon.appspot.com/UA-12846745-20/google-maps-services-python/readme?pixel) +Use Python? Want to geocode something? Looking for directions? +Maybe matrices of directions? This library brings the Google Maps Platform Web +Services to your Python application. The Python Client for Google Maps Services is a Python Client library for the following Google Maps APIs: - - [Directions API] - - [Distance Matrix API] - - [Elevation API] - - [Geocoding API] - - [Time Zone API] - - [Roads API] + - Directions API + - Distance Matrix API + - Elevation API + - Geocoding API + - Geolocation API + - Time Zone API + - Roads API + - Places API + - Maps Static API + - Address Validation API Keep in mind that the same [terms and conditions](https://developers.google.com/maps/terms) apply to usage of the APIs when they're accessed through this library. @@ -31,40 +41,20 @@ to make backwards-incompatible changes. If we do remove some functionality (typi better functionality exists or if the feature proved infeasible), our intention is to deprecate and give developers a year to update their code. -If you find a bug, or have a feature suggestion, please [log an issue][issues]. If you'd like to -contribute, please read [How to Contribute][contrib]. +If you find a bug, or have a feature suggestion, please log an issue. If you'd like to +contribute, please read contribute. ## Requirements - - Python 2.5 or later. + - Python 3.5 or later. - A Google Maps API key. -### API keys - -Each Google Maps Web Service requires an API key or Client ID. API keys are -freely available with a Google Account at https://developers.google.com/console. -To generate a server key for your project: - - 1. Visit https://developers.google.com/console and log in with - a Google Account. - 1. Select an existing project, or create a new project. - 1. Click **Enable an API**. - 1. Browse for the API, and set its status to "On". The Python Client for Google Maps Services - accesses the following APIs: - * Directions API - * Distance Matrix API - * Elevation API - * Geocoding API - * Time Zone API - * Roads API - 1. Once you've enabled the APIs, click **Credentials** from the left navigation of the Developer - Console. - 1. In the "Public API access", click **Create new Key**. - 1. Choose **Server Key**. - 1. If you'd like to restrict requests to a specific IP address, do so now. - 1. Click **Create**. - -Your API key should be 40 characters long, and begin with `AIza`. +## API Keys + +Each Google Maps Web Service request requires an API key or client ID. API keys +are generated in the 'Credentials' page of the 'APIs & Services' tab of [Google Cloud console](https://console.cloud.google.com/apis/credentials). + +For even more information on getting started with Google Maps Platform and generating/restricting an API key, see [Get Started with Google Maps Platform](https://developers.google.com/maps/gmp-get-started) in our docs. **Important:** This key should be kept secret on your server. @@ -74,31 +64,17 @@ Your API key should be 40 characters long, and begin with `AIza`. Note that you will need requests 2.4.0 or higher if you want to specify connect/read timeouts. -## Developer Documentation - -View the [reference documentation](https://googlemaps.github.io/google-maps-services-python/docs/2.2/) - -Additional documentation for the included web services is available at -https://developers.google.com/maps/. - - - [Directions API] - - [Distance Matrix API] - - [Elevation API] - - [Geocoding API] - - [Time Zone API] - - [Roads API] - ## Usage -This example uses the [Geocoding API]. - - +This example uses the Geocoding API and the Directions API with an API key: ```python +import googlemaps +from datetime import datetime gmaps = googlemaps.Client(key='Add Your Key here') -# Geocoding and address +# Geocoding an address geocode_result = gmaps.geocode('1600 Amphitheatre Parkway, Mountain View, CA') # Look up an address with reverse geocoding @@ -110,11 +86,19 @@ directions_result = gmaps.directions("Sydney Town Hall", "Parramatta, NSW", mode="transit", departure_time=now) -``` +# Validate an address with address validation +addressvalidation_result = gmaps.addressvalidation(['1600 Amphitheatre Pk'], + regionCode='US', + locality='Mountain View', + enableUspsCass=True) + +# Get an Address Descriptor of a location in the reverse geocoding response +address_descriptor_result = gmaps.reverse_geocode((40.714224, -73.961452), enable_address_descriptor=True) +``` -For more usage examples, check out [the tests](test/). +For more usage examples, check out [the tests](https://github.com/googlemaps/google-maps-services-python/tree/master/tests). ## Features @@ -123,42 +107,44 @@ For more usage examples, check out [the tests](test/). Automatically retry when intermittent failures occur. That is, when any of the retriable 5xx errors are returned from the API. -### Keys *and* Client IDs - -Maps API for Work customers can use their [client ID and secret][clientid] to authenticate. Free -customers can use their [API key][apikey], too. ## Building the Project - # Installing tox - $ pip install tox + # Installing nox + $ pip install nox # Running tests - $ tox + $ nox # Generating documentation - $ tox -e docs - - # Uploading a new release - $ easy_install wheel twine - $ python setup.py sdist bdist_wheel - $ twine upload dist/* + $ nox -e docs # Copy docs to gh-pages - $ tox -e docs && mv docs/_build/html generated_docs && git clean -Xdi && git checkout gh-pages - - -[apikey]: https://developers.google.com/maps/faq#keysystem -[clientid]: https://developers.google.com/maps/documentation/business/webservices/auth - -[Google Maps API Web Services]: https://developers.google.com/maps/documentation/webservices/ -[Directions API]: https://developers.google.com/maps/documentation/directions/ -[Distance Matrix API]: https://developers.google.com/maps/documentation/distancematrix/ -[Elevation API]: https://developers.google.com/maps/documentation/elevation/ -[Geocoding API]: https://developers.google.com/maps/documentation/geocoding/ -[Time Zone API]: https://developers.google.com/maps/documentation/timezone/ -[Roads API]: https://developers.google.com/maps/documentation/roads/ - -[issues]: https://github.com/googlemaps/google-maps-services-python/issues -[contrib]: https://github.com/googlemaps/google-maps-services-python/blob/master/CONTRIB.md + $ nox -e docs && mv docs/_build/html generated_docs && git clean -Xdi && git checkout gh-pages + +## Documentation & resources + +[Documentation for the `google-maps-services-python` library](https://googlemaps.github.io/google-maps-services-python/docs/index.html) + +### Getting started +- [Get Started with Google Maps Platform](https://developers.google.com/maps/gmp-get-started) +- [Generating/restricting an API key](https://developers.google.com/maps/gmp-get-started#api-key) +- [Authenticating with a client ID](https://developers.google.com/maps/documentation/directions/get-api-key#client-id) + +### API docs +- [Google Maps Platform web services](https://developers.google.com/maps/apis-by-platform#web_service_apis) +- [Directions API](https://developers.google.com/maps/documentation/directions/) +- [Distance Matrix API](https://developers.google.com/maps/documentation/distancematrix/) +- [Elevation API](https://developers.google.com/maps/documentation/elevation/) +- [Geocoding API](https://developers.google.com/maps/documentation/geocoding/) +- [Geolocation API](https://developers.google.com/maps/documentation/geolocation/) +- [Time Zone API](https://developers.google.com/maps/documentation/timezone/) +- [Roads API](https://developers.google.com/maps/documentation/roads/) +- [Places API](https://developers.google.com/places/) +- [Maps Static API](https://developers.google.com/maps/documentation/maps-static/) + +### Support +- [Report an issue](https://github.com/googlemaps/google-maps-services-python/issues) +- [Contribute](https://github.com/googlemaps/google-maps-services-python/blob/master/CONTRIB.md) +- [StackOverflow](http://stackoverflow.com/questions/tagged/google-maps) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..6d19135d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Report a security issue + +To report a security issue, please use https://g.co/vulnz. We use +https://g.co/vulnz for our intake, and do coordination and disclosure here on +GitHub (including using GitHub Security Advisory). The Google Security Team will +respond within 5 working days of your report on g.co/vulnz. + +To contact us about other bugs, please open an issue on GitHub. + +> **Note**: This file is synchronized from the https://github.com/googlemaps/.github repository. diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 00000000..1c38ca3d --- /dev/null +++ b/coverage.xml @@ -0,0 +1,849 @@ + + + + + + /Users/anglarett/Public/Drop Box/dev-se-git/google-maps-services-python + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py index 19cdfef5..0d8314fb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,17 @@ +# Copyright 2022 Google LLC +# +# 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. + # -*- coding: utf-8 -*- # # Maps API documentation build configuration file, created by diff --git a/googlemaps/__init__.py b/googlemaps/__init__.py index 63cfe578..61ec45d0 100644 --- a/googlemaps/__init__.py +++ b/googlemaps/__init__.py @@ -15,10 +15,10 @@ # the License. # -__version__ = "2.3-dev" +__version__ = "4.10.0" from googlemaps.client import Client -import googlemaps.exceptions +from googlemaps import exceptions -# Allow sphinx to pick up these symbols for the documentation. -__all__ = ["Client"] + +__all__ = ["Client", "exceptions"] diff --git a/googlemaps/addressvalidation.py b/googlemaps/addressvalidation.py new file mode 100644 index 00000000..149f3b48 --- /dev/null +++ b/googlemaps/addressvalidation.py @@ -0,0 +1,81 @@ +# +# Copyright 2022 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Performs requests to the Google Maps Address Validation API.""" +from googlemaps import exceptions + + +_ADDRESSVALIDATION_BASE_URL = "https://addressvalidation.googleapis.com" + + +def _addressvalidation_extract(response): + """ + Mimics the exception handling logic in ``client._get_body``, but + for addressvalidation which uses a different response format. + """ + body = response.json() + return body + + # if response.status_code in (200, 404): + # return body + + # try: + # error = body["error"]["errors"][0]["reason"] + # except KeyError: + # error = None + + # if response.status_code == 403: + # raise exceptions._OverQueryLimit(response.status_code, error) + # else: + # raise exceptions.ApiError(response.status_code, error) + + +def addressvalidation(client, addressLines, regionCode=None , locality=None, enableUspsCass=None): + """ + The Google Maps Address Validation API returns a verification of an address + See https://developers.google.com/maps/documentation/address-validation/overview + request must include parameters below. + :param addressLines: The address to validate + :type addressLines: array + :param regionCode: (optional) The country code + :type regionCode: string + :param locality: (optional) Restrict to a locality, ie:Mountain View + :type locality: string + :param enableUspsCass For the "US" and "PR" regions only, you can optionally enable the Coding Accuracy Support System (CASS) from the United States Postal Service (USPS) + :type locality: boolean + """ + + params = { + "address":{ + "addressLines": addressLines + } + } + + if regionCode is not None: + params["address"]["regionCode"] = regionCode + + if locality is not None: + params["address"]["locality"] = locality + + if enableUspsCass is not False or enableUspsCass is not None: + params["enableUspsCass"] = enableUspsCass + + return client._request("/v1:validateAddress", {}, # No GET params + base_url=_ADDRESSVALIDATION_BASE_URL, + extract_body=_addressvalidation_extract, + post_json=params) + \ No newline at end of file diff --git a/googlemaps/client.py b/googlemaps/client.py index 8e66ccc6..d1f4ab6a 100644 --- a/googlemaps/client.py +++ b/googlemaps/client.py @@ -22,13 +22,18 @@ import base64 import collections +import logging from datetime import datetime from datetime import timedelta +import functools import hashlib import hmac +import re import requests import random import time +import math +import sys import googlemaps @@ -37,31 +42,44 @@ except ImportError: # Python 2 from urllib import urlencode +logger = logging.getLogger(__name__) +_X_GOOG_MAPS_EXPERIENCE_ID = "X-Goog-Maps-Experience-ID" _USER_AGENT = "GoogleGeoApiClientPython/%s" % googlemaps.__version__ _DEFAULT_BASE_URL = "https://maps.googleapis.com" -_RETRIABLE_STATUSES = set([500, 503, 504]) +_RETRIABLE_STATUSES = {500, 503, 504} -class Client(object): + +class Client: """Performs requests to the Google Maps API web services.""" def __init__(self, key=None, client_id=None, client_secret=None, timeout=None, connect_timeout=None, read_timeout=None, retry_timeout=60, requests_kwargs=None, - queries_per_second=10): + queries_per_second=60, queries_per_minute=6000,channel=None, + retry_over_query_limit=True, experience_id=None, + requests_session=None, + base_url=_DEFAULT_BASE_URL): """ :param key: Maps API key. Required, unless "client_id" and - "client_secret" are set. + "client_secret" are set. Most users should use an API key. :type key: string :param client_id: (for Maps API for Work customers) Your client ID. + Most users should use an API key instead. :type client_id: string :param client_secret: (for Maps API for Work customers) Your client - secret (base64 encoded). + secret (base64 encoded). Most users should use an API key instead. :type client_secret: string + :param channel: (for Maps API for Work customers) When set, a channel + parameter with this value will be added to the requests. + This can be used for tracking purpose. + Can only be used with a Maps API client ID. + :type channel: str + :param timeout: Combined connect and read timeout for HTTP requests, in seconds. Specify "None" for no timeout. :type timeout: int @@ -80,11 +98,25 @@ def __init__(self, key=None, client_id=None, client_secret=None, seconds. :type retry_timeout: int - :param queries_per_second: Number of queries per second permitted. + :param queries_per_second: Number of queries per second permitted. Unset queries_per_minute to None. If set smaller number will be used. If the rate limit is reached, the client will sleep for the appropriate amount of time before it runs the current query. :type queries_per_second: int + :param queries_per_minute: Number of queries per minute permitted. Unset queries_per_second to None. If set smaller number will be used. + If the rate limit is reached, the client will sleep for the + appropriate amount of time before it runs the current query. + :type queries_per_minute: int + + :param retry_over_query_limit: If True, requests that result in a + response indicating the query rate limit was exceeded will be + retried. Defaults to True. + :type retry_over_query_limit: bool + + :param experience_id: The value for the HTTP header field name + 'X-Goog-Maps-Experience-ID'. + :type experience_id: str + :raises ValueError: when either credentials are missing, incomplete or invalid. :raises NotImplementedError: if connect_timeout and read_timeout are @@ -96,6 +128,13 @@ def __init__(self, key=None, client_id=None, client_secret=None, http://docs.python-requests.org/en/latest/api/#main-interface :type requests_kwargs: dict + :param requests_session: Reused persistent session for flexibility. + :type requests_session: requests.Session + + :param base_url: The base URL for all requests. Defaults to the Maps API + server. Should not have a trailing slash. + :type base_url: string + """ if not key and not (client_secret and client_id): raise ValueError("Must provide API key or enterprise credentials " @@ -104,16 +143,24 @@ def __init__(self, key=None, client_id=None, client_secret=None, if key and not key.startswith("AIza"): raise ValueError("Invalid API key provided.") + if channel: + if not re.match("^[a-zA-Z0-9._-]*$", channel): + raise ValueError("The channel argument must be an ASCII " + "alphanumeric string. The period (.), underscore (_)" + "and hyphen (-) characters are allowed. If used without " + "client_id, it must be 0-999.") + + self.session = requests_session or requests.Session() self.key = key if timeout and (connect_timeout or read_timeout): - raise ValueError("Specify either timeout, or connect_timeout " + + raise ValueError("Specify either timeout, or connect_timeout " "and read_timeout") if connect_timeout and read_timeout: # Check that the version of requests is >= 2.4.0 chunks = requests.__version__.split(".") - if chunks[0] < 2 or (chunks[0] == 2 and chunks[1] < 4): + if int(chunks[0]) < 2 or (int(chunks[0]) == 2 and int(chunks[1]) < 4): raise NotImplementedError("Connect/Read timeouts require " "requests v2.4.0 or higher") self.timeout = (connect_timeout, read_timeout) @@ -122,47 +169,119 @@ def __init__(self, key=None, client_id=None, client_secret=None, self.client_id = client_id self.client_secret = client_secret + self.channel = channel self.retry_timeout = timedelta(seconds=retry_timeout) self.requests_kwargs = requests_kwargs or {} + headers = self.requests_kwargs.pop('headers', {}) + headers.update({"User-Agent": _USER_AGENT}) self.requests_kwargs.update({ - "headers": {"User-Agent": _USER_AGENT}, + "headers": headers, "timeout": self.timeout, "verify": True, # NOTE(cbro): verify SSL certs. }) + + self.queries_per_second = queries_per_second + self.queries_per_minute = queries_per_minute + try: + if (type(self.queries_per_second) == int and type(self.queries_per_minute) == int ): + self.queries_quota = math.floor(min(self.queries_per_second, self.queries_per_minute/60)) + elif (self.queries_per_second and type(self.queries_per_second) == int ): + self.queries_quota = math.floor(self.queries_per_second) + elif (self.queries_per_minute and type(self.queries_per_minute) == int ): + self.queries_quota = math.floor(self.queries_per_minute/60) + else: + sys.exit("MISSING VALID NUMBER for queries_per_second or queries_per_minute") + logger.info("API queries_quota: %s", self.queries_quota) + + except NameError: + sys.exit("MISSING VALUE for queries_per_second or queries_per_minute") + + self.retry_over_query_limit = retry_over_query_limit + self.sent_times = collections.deque("", self.queries_quota) + self.set_experience_id(experience_id) + self.base_url = base_url - self.sent_times = collections.deque("", queries_per_second) + def set_experience_id(self, *experience_id_args): + """Sets the value for the HTTP header field name + 'X-Goog-Maps-Experience-ID' to be used on subsequent API calls. - def _get(self, url, params, first_request_time=None, retry_counter=0, - base_url=_DEFAULT_BASE_URL, accepts_clientid=True, extract_body=None): - """Performs HTTP GET request with credentials, returning the body as + :param experience_id_args: the experience ID + :type experience_id_args: string varargs + """ + if len(experience_id_args) == 0 or experience_id_args[0] is None: + self.clear_experience_id() + return + + headers = self.requests_kwargs.pop("headers", {}) + headers[_X_GOOG_MAPS_EXPERIENCE_ID] = ",".join(experience_id_args) + self.requests_kwargs["headers"] = headers + + def get_experience_id(self): + """Gets the experience ID for the HTTP header field name + 'X-Goog-Maps-Experience-ID' + + :return: The experience ID if set + :rtype: str + """ + headers = self.requests_kwargs.get("headers", {}) + return headers.get(_X_GOOG_MAPS_EXPERIENCE_ID, None) + + def clear_experience_id(self): + """Clears the experience ID for the HTTP header field name + 'X-Goog-Maps-Experience-ID' if set. + """ + headers = self.requests_kwargs.get("headers") + if headers is None: + return + headers.pop(_X_GOOG_MAPS_EXPERIENCE_ID, {}) + self.requests_kwargs["headers"] = headers + + def _request(self, url, params, first_request_time=None, retry_counter=0, + base_url=None, accepts_clientid=True, + extract_body=None, requests_kwargs=None, post_json=None): + """Performs HTTP GET/POST with credentials, returning the body as JSON. :param url: URL path for the request. Should begin with a slash. :type url: string + :param params: HTTP GET parameters. :type params: dict or list of key/value tuples - :param first_request_time: The time of the first request (None if no retries - have occurred). + + :param first_request_time: The time of the first request (None if no + retries have occurred). :type first_request_time: datetime.datetime + :param retry_counter: The number of this retry, or zero for first attempt. :type retry_counter: int + :param base_url: The base URL for the request. Defaults to the Maps API - server. Should not have a trailing slash. + server. Should not have a trailing slash. :type base_url: string + :param accepts_clientid: Whether this call supports the client/signature - params. Some APIs require API keys (e.g. Roads). + params. Some APIs require API keys (e.g. Roads). :type accepts_clientid: bool + :param extract_body: A function that extracts the body from the request. - If the request was not successful, the function should raise a - googlemaps.HTTPError or googlemaps.ApiError as appropriate. + If the request was not successful, the function should raise a + googlemaps.HTTPError or googlemaps.ApiError as appropriate. :type extract_body: function + :param requests_kwargs: Same extra keywords arg for requests as per + __init__, but provided here to allow overriding internally on a + per-request basis. + :type requests_kwargs: dict + :raises ApiError: when the API returns an error. :raises Timeout: if the request timed out. :raises TransportError: when something went wrong while trying to - exceute a request. + exceute a request. """ + if base_url is None: + base_url = self.base_url + if not first_request_time: first_request_time = datetime.now() @@ -181,73 +300,99 @@ def _get(self, url, params, first_request_time=None, retry_counter=0, authed_url = self._generate_auth_url(url, params, accepts_clientid) + # Default to the client-level self.requests_kwargs, with method-level + # requests_kwargs arg overriding. + requests_kwargs = requests_kwargs or {} + final_requests_kwargs = dict(self.requests_kwargs, **requests_kwargs) + + # Determine GET/POST. + requests_method = self.session.get + if post_json is not None: + requests_method = self.session.post + final_requests_kwargs["json"] = post_json + try: - resp = requests.get(base_url + authed_url, **self.requests_kwargs) + response = requests_method(base_url + authed_url, + **final_requests_kwargs) except requests.exceptions.Timeout: raise googlemaps.exceptions.Timeout() except Exception as e: raise googlemaps.exceptions.TransportError(e) - if resp.status_code in _RETRIABLE_STATUSES: + if response.status_code in _RETRIABLE_STATUSES: # Retry request. - return self._get(url, params, first_request_time, retry_counter + 1, - base_url, accepts_clientid, extract_body) - - # Check if the time of the nth previous query (where n is queries_per_second) - # is under a second ago - if so, sleep for the difference. - if self.sent_times and len(self.sent_times) == self.sent_times.maxlen: + return self._request(url, params, first_request_time, + retry_counter + 1, base_url, accepts_clientid, + extract_body, requests_kwargs, post_json) + + # Check if the time of the nth previous query (where n is + # queries_per_second) is under a second ago - if so, sleep for + # the difference. + if self.sent_times and len(self.sent_times) == self.queries_quota: elapsed_since_earliest = time.time() - self.sent_times[0] if elapsed_since_earliest < 1: time.sleep(1 - elapsed_since_earliest) try: if extract_body: - result = extract_body(resp) + result = extract_body(response) else: - result = self._get_body(resp) + result = self._get_body(response) self.sent_times.append(time.time()) return result - except googlemaps.exceptions._RetriableRequest: + except googlemaps.exceptions._RetriableRequest as e: + if isinstance(e, googlemaps.exceptions._OverQueryLimit) and not self.retry_over_query_limit: + raise + # Retry request. - return self._get(url, params, first_request_time, retry_counter + 1, - base_url, accepts_clientid, extract_body) + return self._request(url, params, first_request_time, + retry_counter + 1, base_url, accepts_clientid, + extract_body, requests_kwargs, post_json) + + def _get(self, *args, **kwargs): # Backwards compatibility. + return self._request(*args, **kwargs) - def _get_body(self, resp): - if resp.status_code != 200: - raise googlemaps.exceptions.HTTPError(resp.status_code) + def _get_body(self, response): + if response.status_code != 200: + raise googlemaps.exceptions.HTTPError(response.status_code) - body = resp.json() + body = response.json() api_status = body["status"] if api_status == "OK" or api_status == "ZERO_RESULTS": return body if api_status == "OVER_QUERY_LIMIT": - raise googlemaps.exceptions._RetriableRequest() + raise googlemaps.exceptions._OverQueryLimit( + api_status, body.get("error_message")) - if "error_message" in body: - raise googlemaps.exceptions.ApiError(api_status, - body["error_message"]) - else: - raise googlemaps.exceptions.ApiError(api_status) + raise googlemaps.exceptions.ApiError(api_status, + body.get("error_message")) def _generate_auth_url(self, path, params, accepts_clientid): """Returns the path and query string portion of the request URL, first adding any necessary parameters. + :param path: The path portion of the URL. :type path: string + :param params: URL parameters. :type params: dict or list of key/value tuples + :rtype: string + """ # Deterministic ordering through sorting by key. # Useful for tests, and in the future, any caching. + extra_params = getattr(self, "_extra_params", None) or {} if type(params) is dict: - params = sorted(params.items()) + params = sorted(dict(extra_params, **params).items()) else: - params = params[:] # Take a copy. + params = sorted(extra_params.items()) + params[:] # Take a copy. if accepts_clientid and self.client_id and self.client_secret: + if self.channel: + params.append(("channel", self.channel)) params.append(("client", self.client_id)) path = "?".join([path, urlencode_params(params)]) @@ -268,29 +413,76 @@ def _generate_auth_url(self, path, params, accepts_clientid): from googlemaps.elevation import elevation_along_path from googlemaps.geocoding import geocode from googlemaps.geocoding import reverse_geocode +from googlemaps.geolocation import geolocate from googlemaps.timezone import timezone from googlemaps.roads import snap_to_roads +from googlemaps.roads import nearest_roads from googlemaps.roads import speed_limits from googlemaps.roads import snapped_speed_limits +from googlemaps.places import find_place +from googlemaps.places import places +from googlemaps.places import places_nearby +from googlemaps.places import place +from googlemaps.places import places_photo +from googlemaps.places import places_autocomplete +from googlemaps.places import places_autocomplete_query +from googlemaps.maps import static_map +from googlemaps.addressvalidation import addressvalidation + +def make_api_method(func): + """ + Provides a single entry point for modifying all API methods. + For now this is limited to allowing the client object to be modified + with an `extra_params` keyword arg to each method, that is then used + as the params for each web service request. -Client.directions = directions -Client.distance_matrix = distance_matrix -Client.elevation = elevation -Client.elevation_along_path = elevation_along_path -Client.geocode = geocode -Client.reverse_geocode = reverse_geocode -Client.timezone = timezone -Client.snap_to_roads = snap_to_roads -Client.speed_limits = speed_limits -Client.snapped_speed_limits = snapped_speed_limits + Please note that this is an unsupported feature for advanced use only. + It's also currently incompatibile with multiple threads, see GH #160. + """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + args[0]._extra_params = kwargs.pop("extra_params", None) + result = func(*args, **kwargs) + try: + del args[0]._extra_params + except AttributeError: + pass + return result + return wrapper + + +Client.directions = make_api_method(directions) +Client.distance_matrix = make_api_method(distance_matrix) +Client.elevation = make_api_method(elevation) +Client.elevation_along_path = make_api_method(elevation_along_path) +Client.geocode = make_api_method(geocode) +Client.reverse_geocode = make_api_method(reverse_geocode) +Client.geolocate = make_api_method(geolocate) +Client.timezone = make_api_method(timezone) +Client.snap_to_roads = make_api_method(snap_to_roads) +Client.nearest_roads = make_api_method(nearest_roads) +Client.speed_limits = make_api_method(speed_limits) +Client.snapped_speed_limits = make_api_method(snapped_speed_limits) +Client.find_place = make_api_method(find_place) +Client.places = make_api_method(places) +Client.places_nearby = make_api_method(places_nearby) +Client.place = make_api_method(place) +Client.places_photo = make_api_method(places_photo) +Client.places_autocomplete = make_api_method(places_autocomplete) +Client.places_autocomplete_query = make_api_method(places_autocomplete_query) +Client.static_map = make_api_method(static_map) +Client.addressvalidation = make_api_method(addressvalidation) def sign_hmac(secret, payload): """Returns a base64-encoded HMAC-SHA1 signature of a given string. + :param secret: The key used for the signature, base64 encoded. :type secret: string + :param payload: The payload to sign. :type payload: string + :rtype: string """ payload = payload.encode('ascii', 'strict') @@ -302,16 +494,25 @@ def sign_hmac(secret, payload): def urlencode_params(params): """URL encodes the parameters. + :param params: The parameters :type params: list of key/value tuples. + + :rtype: string """ # urlencode does not handle unicode strings in Python 2. # Firstly, normalize the values so they get encoded correctly. - params = [(key, normalize_for_urlencode(val)) for key, val in params] + extended = [] + for key, val in params: + if isinstance(val, (list, tuple)): + for v in val: + extended.append((key, normalize_for_urlencode(v))) + else: + extended.append((key, normalize_for_urlencode(val))) # Secondly, unquote unreserved chars which are incorrectly quoted # by urllib.urlencode, causing invalid auth signatures. See GH #72 # for more info. - return requests.utils.unquote_unreserved(urlencode(params)) + return requests.utils.unquote_unreserved(urlencode(extended)) try: @@ -333,4 +534,7 @@ def normalize_for_urlencode(value): def normalize_for_urlencode(value): """(Python 3) No-op.""" # urlencode in Python 3 handles all the types we are passing it. - return value + if isinstance(value, str): + return value + + return normalize_for_urlencode(str(value)) diff --git a/googlemaps/convert.py b/googlemaps/convert.py index c9cfe350..2b3d056e 100644 --- a/googlemaps/convert.py +++ b/googlemaps/convert.py @@ -28,7 +28,31 @@ # '-33.8674869,151.2069902' """ -import time as _time + +def format_float(arg): + """Formats a float value to be as short as possible. + + Truncates float to 8 decimal places and trims extraneous + trailing zeros and period to give API args the best + possible chance of fitting within 2000 char URL length + restrictions. + + For example: + + format_float(40) -> "40" + format_float(40.0) -> "40" + format_float(40.1) -> "40.1" + format_float(40.001) -> "40.001" + format_float(40.0010) -> "40.001" + format_float(40.000000001) -> "40" + format_float(40.000000009) -> "40.00000001" + + :param arg: The lat or lng float. + :type arg: float + + :rtype: string + """ + return ("%.8f" % float(arg)).rstrip("0").rstrip(".") def latlng(arg): @@ -44,10 +68,18 @@ def latlng(arg): convert.latlng(sydney) # '-33.8674869,151.2069902' + For convenience, also accepts lat/lon pair as a string, in + which case it's returned unchanged. + :param arg: The lat/lon pair. - :type arg: dict or list or tuple + :type arg: string or dict or list or tuple """ - return "%f,%f" % normalize_lat_lng(arg) + if is_string(arg): + return arg + + normalized = normalize_lat_lng(arg) + return "%s,%s" % (format_float(normalized[0]), format_float(normalized[1])) + def normalize_lat_lng(arg): """Take the various lat/lng representations and return a tuple. @@ -76,12 +108,36 @@ def normalize_lat_lng(arg): "but got %s" % type(arg).__name__) +def location_list(arg): + """Joins a list of locations into a pipe separated string, handling + the various formats supported for lat/lng values. + + For example: + p = [{"lat" : -33.867486, "lng" : 151.206990}, "Sydney"] + convert.waypoint(p) + # '-33.867486,151.206990|Sydney' + + :param arg: The lat/lng list. + :type arg: list + + :rtype: string + """ + if isinstance(arg, tuple): + # Handle the single-tuple lat/lng case. + return latlng(arg) + else: + return "|".join([latlng(location) for location in as_list(arg)]) + + def join_list(sep, arg): """If arg is list-like, then joins it with sep. + :param sep: Separator string. :type sep: string + :param arg: Value to coerce into a list. - :type arg: string or list of string + :type arg: string or list of strings + :rtype: string """ return sep.join(as_list(arg)) @@ -90,6 +146,7 @@ def join_list(sep, arg): def as_list(arg): """Coerces arg into a list. If arg is already list-like, returns arg. Otherwise, returns a one-element list containing arg. + :rtype: list """ if _is_list(arg): @@ -103,9 +160,8 @@ def _is_list(arg): return False if isinstance(arg, str): # Python 3-only, as str has __iter__ return False - return (not _has_method(arg, "strip") - and _has_method(arg, "__getitem__") - or _has_method(arg, "__iter__")) + return _has_method(arg, "__getitem__") if not _has_method(arg, "strip") else _has_method(arg, "__iter__") + def is_string(val): """Determines whether the passed value is a string, safe for 2/3.""" @@ -115,6 +171,7 @@ def is_string(val): return isinstance(val, str) return isinstance(val, basestring) + def time(arg): """Converts the value into a unix time (seconds since unix epoch). @@ -126,8 +183,8 @@ def time(arg): :type arg: datetime.datetime or int """ # handle datetime instances. - if _has_method(arg, "timetuple"): - arg = _time.mktime(arg.timetuple()) + if _has_method(arg, "timestamp"): + arg = arg.timestamp() if isinstance(arg, float): arg = int(arg) @@ -139,8 +196,10 @@ def _has_method(arg, method): """Returns true if the given object has a method with the given name. :param arg: the object + :param method: the method name :type method: string + :rtype: bool """ return hasattr(arg, method) and callable(getattr(arg, method)) @@ -157,11 +216,20 @@ def components(arg): :param arg: The component filter. :type arg: dict + :rtype: basestring """ + + # Components may have multiple values per type, here we + # expand them into individual key/value items, eg: + # {"country": ["US", "AU"], "foo": 1} -> "country:AU", "country:US", "foo:1" + def expand(arg): + for k, v in arg.items(): + for item in as_list(v): + yield "%s:%s" % (k, item) + if isinstance(arg, dict): - arg = sorted(["%s:%s" % (k, arg[k]) for k in arg]) - return "|".join(arg) + return "|".join(sorted(expand(arg))) raise TypeError( "Expected a dict for components, " @@ -196,7 +264,9 @@ def bounds(arg): :type arg: dict """ - if isinstance(arg, dict): + if is_string(arg) and arg.count("|") == 1 and arg.count(",") == 2: + return arg + elif isinstance(arg, dict): if "southwest" in arg and "northeast" in arg: return "%s|%s" % (latlng(arg["southwest"]), latlng(arg["northeast"])) @@ -206,6 +276,17 @@ def bounds(arg): "but got %s" % type(arg).__name__) +def size(arg): + if isinstance(arg, int): + return "%sx%s" % (arg, arg) + elif _is_list(arg): + return "%sx%s" % (arg[0], arg[1]) + + raise TypeError( + "Expected a size int or list, " + "but got %s" % type(arg).__name__) + + def decode_polyline(polyline): """Decodes a Polyline string into a list of lat/lng dicts. @@ -280,3 +361,26 @@ def encode_polyline(points): last_lng = lng return result + + +def shortest_path(locations): + """Returns the shortest representation of the given locations. + + The Elevations API limits requests to 2000 characters, and accepts + multiple locations either as pipe-delimited lat/lng values, or + an encoded polyline, so we determine which is shortest and use it. + + :param locations: The lat/lng list. + :type locations: list + + :rtype: string + """ + if isinstance(locations, tuple): + # Handle the single-tuple lat/lng case. + locations = [locations] + encoded = "enc:%s" % encode_polyline(locations) + unencoded = location_list(locations) + if len(encoded) < len(unencoded): + return encoded + else: + return unencoded diff --git a/googlemaps/directions.py b/googlemaps/directions.py index 4864e967..353145cc 100644 --- a/googlemaps/directions.py +++ b/googlemaps/directions.py @@ -24,23 +24,28 @@ def directions(client, origin, destination, mode=None, waypoints=None, alternatives=False, avoid=None, language=None, units=None, region=None, departure_time=None, arrival_time=None, optimize_waypoints=False, transit_mode=None, - transit_routing_preference=None): + transit_routing_preference=None, traffic_model=None): """Get directions between an origin point and a destination point. :param origin: The address or latitude/longitude value from which you wish - to calculate directions. - :type origin: string or dict or tuple + to calculate directions. + :type origin: string, dict, list, or tuple :param destination: The address or latitude/longitude value from which - you wish to calculate directions. - :type destination: string or dict or tuple + you wish to calculate directions. You can use a place_id as destination + by putting 'place_id:' as a prefix in the passing parameter. + :type destination: string, dict, list, or tuple :param mode: Specifies the mode of transport to use when calculating directions. One of "driving", "walking", "bicycling" or "transit" :type mode: string :param waypoints: Specifies an array of waypoints. Waypoints alter a - route by routing it through the specified location(s). + route by routing it through the specified location(s). To influence + route without adding stop prefix the waypoint with `via`, similar to + `waypoints = ["via:San Francisco", "via:Mountain View"]`. + :type waypoints: a single location, or a list of locations, where a + location is a string, dict, list, or tuple :param alternatives: If True, more than one route may be returned in the response. @@ -83,12 +88,19 @@ def directions(client, origin, destination, requests. Valid values are "less_walking" or "fewer_transfers" :type transit_routing_preference: string + :param traffic_model: Specifies the predictive travel time model to use. + Valid values are "best_guess" or "optimistic" or "pessimistic". + The traffic_model parameter may only be specified for requests where + the travel mode is driving, and where the request includes a + departure_time. + :type units: string + :rtype: list of routes """ params = { - "origin": _convert_waypoint(origin), - "destination": _convert_waypoint(destination) + "origin": convert.latlng(origin), + "destination": convert.latlng(destination) } if mode: @@ -99,13 +111,10 @@ def directions(client, origin, destination, params["mode"] = mode if waypoints: - waypoints = convert.as_list(waypoints) - waypoints = [_convert_waypoint(waypoint) for waypoint in waypoints] - + waypoints = convert.location_list(waypoints) if optimize_waypoints: - waypoints = ["optimize:true"] + waypoints - - params["waypoints"] = convert.join_list("|", waypoints) + waypoints = "optimize:true|" + waypoints + params["waypoints"] = waypoints if alternatives: params["alternatives"] = "true" @@ -138,10 +147,7 @@ def directions(client, origin, destination, if transit_routing_preference: params["transit_routing_preference"] = transit_routing_preference - return client._get("/maps/api/directions/json", params)["routes"] - -def _convert_waypoint(waypoint): - if not convert.is_string(waypoint): - return convert.latlng(waypoint) + if traffic_model: + params["traffic_model"] = traffic_model - return waypoint + return client._request("/maps/api/directions/json", params).get("routes", []) diff --git a/googlemaps/distance_matrix.py b/googlemaps/distance_matrix.py old mode 100644 new mode 100755 index 6d62ef0b..a30cbe09 --- a/googlemaps/distance_matrix.py +++ b/googlemaps/distance_matrix.py @@ -18,40 +18,44 @@ """Performs requests to the Google Maps Distance Matrix API.""" from googlemaps import convert -from googlemaps.convert import as_list + def distance_matrix(client, origins, destinations, mode=None, language=None, avoid=None, units=None, departure_time=None, arrival_time=None, transit_mode=None, - transit_routing_preference=None): + transit_routing_preference=None, traffic_model=None, region=None): """ Gets travel distance and time for a matrix of origins and destinations. - :param origins: One or more addresses and/or latitude/longitude values, - from which to calculate distance and time. If you pass an address - as a string, the service will geocode the string and convert it to - a latitude/longitude coordinate to calculate directions. - :type origins: list of strings, dicts or tuples - - :param destinations: One or more addresses and/or lat/lng values, to - which to calculate distance and time. If you pass an address as a - string, the service will geocode the string and convert it to a - latitude/longitude coordinate to calculate directions. - :type destinations: list of strings, dicts or tuples + :param origins: One or more addresses, Place IDs, and/or latitude/longitude + values, from which to calculate distance and time. Each Place ID string + must be prepended with 'place_id:'. If you pass an address as a string, + the service will geocode the string and convert it to a + latitude/longitude coordinate to calculate directions. + :type origins: a single location, or a list of locations, where a + location is a string, dict, list, or tuple + + :param destinations: One or more addresses, Place IDs, and/or lat/lng values + , to which to calculate distance and time. Each Place ID string must be + prepended with 'place_id:'. If you pass an address as a string, the + service will geocode the string and convert it to a latitude/longitude + coordinate to calculate directions. + :type destinations: a single location, or a list of locations, where a + location is a string, dict, list, or tuple :param mode: Specifies the mode of transport to use when calculating - directions. Valid values are "driving", "walking", "transit" or - "bicycling". + directions. Valid values are "driving", "walking", "transit" or + "bicycling". :type mode: string :param language: The language in which to return results. :type language: string :param avoid: Indicates that the calculated route(s) should avoid the - indicated features. Valid values are "tolls", "highways" or "ferries" + indicated features. Valid values are "tolls", "highways" or "ferries". :type avoid: string :param units: Specifies the unit system to use when displaying results. - Valid values are "metric" or "imperial" + Valid values are "metric" or "imperial". :type units: string :param departure_time: Specifies the desired time of departure. @@ -69,16 +73,27 @@ def distance_matrix(client, origins, destinations, :type transit_mode: string or list of strings :param transit_routing_preference: Specifies preferences for transit - requests. Valid values are "less_walking" or "fewer_transfers" + requests. Valid values are "less_walking" or "fewer_transfers". :type transit_routing_preference: string + :param traffic_model: Specifies the predictive travel time model to use. + Valid values are "best_guess" or "optimistic" or "pessimistic". + The traffic_model parameter may only be specified for requests where + the travel mode is driving, and where the request includes a + departure_time. + + :param region: Specifies the prefered region the geocoder should search + first, but it will not restrict the results to only this region. Valid + values are a ccTLD code. + :type region: string + :rtype: matrix of distances. Results are returned in rows, each row containing one origin paired with each destination. """ params = { - "origins": _convert_path(origins), - "destinations": _convert_path(destinations) + "origins": convert.location_list(origins), + "destinations": convert.location_list(destinations) } if mode: @@ -115,16 +130,10 @@ def distance_matrix(client, origins, destinations, if transit_routing_preference: params["transit_routing_preference"] = transit_routing_preference - return client._get("/maps/api/distancematrix/json", params) - + if traffic_model: + params["traffic_model"] = traffic_model -def _convert_path(waypoints): - # Handle the single-tuple case - if type(waypoints) is tuple: - waypoints = [waypoints] - else: - waypoints = as_list(waypoints) + if region: + params["region"] = region - return convert.join_list("|", - [(k if convert.is_string(k) else convert.latlng(k)) - for k in waypoints]) + return client._request("/maps/api/distancematrix/json", params) diff --git a/googlemaps/elevation.py b/googlemaps/elevation.py index 70ecf01e..8eb6b14a 100644 --- a/googlemaps/elevation.py +++ b/googlemaps/elevation.py @@ -16,41 +16,37 @@ # """Performs requests to the Google Maps Elevation API.""" + from googlemaps import convert + def elevation(client, locations): """ Provides elevation data for locations provided on the surface of the earth, including depth locations on the ocean floor (which return negative values) - :param locations: A single latitude/longitude tuple or dict, or a list of - latitude/longitude tuples or dicts from which you wish to calculate - elevation data. - :type locations: list or tuple + :param locations: List of latitude/longitude values from which you wish + to calculate elevation data. + :type locations: a single location, or a list of locations, where a + location is a string, dict, list, or tuple :rtype: list of elevation data responses """ - params = {} - if type(locations) is tuple: - locations = [locations] - - params["locations"] = convert.join_list("|", - [convert.latlng(k) for k in convert.as_list(locations)]) + params = {"locations": convert.shortest_path(locations)} + return client._request("/maps/api/elevation/json", params).get("results", []) - return client._get("/maps/api/elevation/json", params)["results"] def elevation_along_path(client, path, samples): """ Provides elevation data sampled along a path on the surface of the earth. - :param path: A encoded polyline string, or a list of - latitude/longitude tuples from which you wish to calculate - elevation data. - :type path: str or list + :param path: An encoded polyline string, or a list of latitude/longitude + values from which you wish to calculate elevation data. + :type path: string, dict, list, or tuple :param samples: The number of sample points along a path for which to - return elevation data. + return elevation data. :type samples: int :rtype: list of elevation data responses @@ -59,12 +55,11 @@ def elevation_along_path(client, path, samples): if type(path) is str: path = "enc:%s" % path else: - path = convert.join_list("|", - [convert.latlng(k) for k in convert.as_list(path)]) + path = convert.shortest_path(path) params = { "path": path, "samples": samples } - return client._get("/maps/api/elevation/json", params)["results"] + return client._request("/maps/api/elevation/json", params).get("results", []) diff --git a/googlemaps/exceptions.py b/googlemaps/exceptions.py index 4197a650..0a0f116a 100644 --- a/googlemaps/exceptions.py +++ b/googlemaps/exceptions.py @@ -27,7 +27,7 @@ def __init__(self, status, message=None): def __str__(self): if self.message is None: - return self.status + return str(self.status) else: return "%s (%s)" % (self.status, self.message) @@ -58,3 +58,11 @@ class Timeout(Exception): class _RetriableRequest(Exception): """Signifies that the request can be retried.""" pass + +class _OverQueryLimit(ApiError, _RetriableRequest): + """Signifies that the request failed because the client exceeded its query rate limit. + + Normally we treat this as a retriable condition, but we allow the calling code to specify that these requests should + not be retried. + """ + pass diff --git a/googlemaps/geocoding.py b/googlemaps/geocoding.py index fe37fc92..590bb627 100644 --- a/googlemaps/geocoding.py +++ b/googlemaps/geocoding.py @@ -19,7 +19,7 @@ from googlemaps import convert -def geocode(client, address=None, components=None, bounds=None, region=None, +def geocode(client, address=None, place_id=None, components=None, bounds=None, region=None, language=None): """ Geocoding is the process of converting addresses @@ -30,24 +30,28 @@ def geocode(client, address=None, components=None, bounds=None, region=None, :param address: The address to geocode. :type address: string - :param components: A component filter for which you wish to obtain a geocode, - for example: - ``{'administrative_area': 'TX','country': 'US'}`` + :param place_id: A textual identifier that uniquely identifies a place, + returned from a Places search. + :type place_id: string + + :param components: A component filter for which you wish to obtain a + geocode, for example: ``{'administrative_area': 'TX','country': 'US'}`` :type components: dict :param bounds: The bounding box of the viewport within which to bias geocode - results more prominently. + results more prominently. :type bounds: string or dict with northeast and southwest keys. :param region: The region code, specified as a ccTLD ("top-level domain") - two-character value. + two-character value. :type region: string :param language: The language in which to return results. - :type langauge: string - - :rtype: list of geocoding results. + :type language: string + :rtype: result dict with the following keys: + status: status code + results: list of geocoding results """ params = {} @@ -55,6 +59,9 @@ def geocode(client, address=None, components=None, bounds=None, region=None, if address: params["address"] = address + if place_id: + params["place_id"] = place_id + if components: params["components"] = convert.components(components) @@ -67,35 +74,40 @@ def geocode(client, address=None, components=None, bounds=None, region=None, if language: params["language"] = language - return client._get("/maps/api/geocode/json", params)["results"] + return client._request("/maps/api/geocode/json", params) def reverse_geocode(client, latlng, result_type=None, location_type=None, - language=None): + language=None, enable_address_descriptor=False): """ Reverse geocoding is the process of converting geographic coordinates into a human-readable address. - :param latlng: The latitude/longitude value for which you wish to obtain the - closest, human-readable address - :type latlng: dict or list or tuple + :param latlng: The latitude/longitude value or place_id for which you wish + to obtain the closest, human-readable address. + :type latlng: string, dict, list, or tuple :param result_type: One or more address types to restrict results to. - :type result_type: string or list of string + :type result_type: string or list of strings :param location_type: One or more location types to restrict results to. - :type location_type: list of string + :type location_type: list of strings :param language: The language in which to return results. - :type langauge: string - - :rtype: list of reverse geocoding results. + :type language: string + :rtype: result dict with the following keys: + status: status code + results: list of reverse geocoding results + address_descriptor: address descriptor for the target """ - params = { - "latlng": convert.latlng(latlng) - } + # Check if latlng param is a place_id string. + # place_id strings do not contain commas; latlng strings do. + if convert.is_string(latlng) and ',' not in latlng: + params = {"place_id": latlng} + else: + params = {"latlng": convert.latlng(latlng)} if result_type: params["result_type"] = convert.join_list("|", result_type) @@ -106,4 +118,7 @@ def reverse_geocode(client, latlng, result_type=None, location_type=None, if language: params["language"] = language - return client._get("/maps/api/geocode/json", params)["results"] + if enable_address_descriptor: + params["enable_address_descriptor"] = "true" + + return client._request("/maps/api/geocode/json", params) diff --git a/googlemaps/geolocation.py b/googlemaps/geolocation.py new file mode 100644 index 00000000..c8db15ec --- /dev/null +++ b/googlemaps/geolocation.py @@ -0,0 +1,107 @@ +# +# Copyright 2017 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Performs requests to the Google Maps Geolocation API.""" +from googlemaps import exceptions + + +_GEOLOCATION_BASE_URL = "https://www.googleapis.com" + + +def _geolocation_extract(response): + """ + Mimics the exception handling logic in ``client._get_body``, but + for geolocation which uses a different response format. + """ + body = response.json() + if response.status_code in (200, 404): + return body + + try: + error = body["error"]["errors"][0]["reason"] + except KeyError: + error = None + + if response.status_code == 403: + raise exceptions._OverQueryLimit(response.status_code, error) + else: + raise exceptions.ApiError(response.status_code, error) + + +def geolocate(client, home_mobile_country_code=None, + home_mobile_network_code=None, radio_type=None, carrier=None, + consider_ip=None, cell_towers=None, wifi_access_points=None): + """ + The Google Maps Geolocation API returns a location and accuracy + radius based on information about cell towers and WiFi nodes given. + + See https://developers.google.com/maps/documentation/geolocation/intro + for more info, including more detail for each parameter below. + + :param home_mobile_country_code: The mobile country code (MCC) for + the device's home network. + :type home_mobile_country_code: string + + :param home_mobile_network_code: The mobile network code (MCC) for + the device's home network. + :type home_mobile_network_code: string + + :param radio_type: The mobile radio type. Supported values are + lte, gsm, cdma, and wcdma. While this field is optional, it + should be included if a value is available, for more accurate + results. + :type radio_type: string + + :param carrier: The carrier name. + :type carrier: string + + :param consider_ip: Specifies whether to fall back to IP geolocation + if wifi and cell tower signals are not available. Note that the + IP address in the request header may not be the IP of the device. + :type consider_ip: bool + + :param cell_towers: A list of cell tower dicts. See + https://developers.google.com/maps/documentation/geolocation/intro#cell_tower_object + for more detail. + :type cell_towers: list of dicts + + :param wifi_access_points: A list of WiFi access point dicts. See + https://developers.google.com/maps/documentation/geolocation/intro#wifi_access_point_object + for more detail. + :type wifi_access_points: list of dicts + """ + + params = {} + if home_mobile_country_code is not None: + params["homeMobileCountryCode"] = home_mobile_country_code + if home_mobile_network_code is not None: + params["homeMobileNetworkCode"] = home_mobile_network_code + if radio_type is not None: + params["radioType"] = radio_type + if carrier is not None: + params["carrier"] = carrier + if consider_ip is not None: + params["considerIp"] = consider_ip + if cell_towers is not None: + params["cellTowers"] = cell_towers + if wifi_access_points is not None: + params["wifiAccessPoints"] = wifi_access_points + + return client._request("/geolocation/v1/geolocate", {}, # No GET params + base_url=_GEOLOCATION_BASE_URL, + extract_body=_geolocation_extract, + post_json=params) diff --git a/googlemaps/maps.py b/googlemaps/maps.py new file mode 100644 index 00000000..746223d6 --- /dev/null +++ b/googlemaps/maps.py @@ -0,0 +1,247 @@ +# +# Copyright 2020 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Performs requests to the Google Maps Static API.""" + +from googlemaps import convert + + +MAPS_IMAGE_FORMATS = {'png8', 'png', 'png32', 'gif', 'jpg', 'jpg-baseline'} + +MAPS_MAP_TYPES = {'roadmap', 'satellite', 'terrain', 'hybrid'} + +class StaticMapParam: + """Base class to handle parameters for Maps Static API.""" + + def __init__(self): + self.params = [] + + def __str__(self): + """Converts a list of parameters to the format expected by + the Google Maps server. + + :rtype: str + + """ + return convert.join_list('|', self.params) + + +class StaticMapMarker(StaticMapParam): + """Handles marker parameters for Maps Static API.""" + + def __init__(self, locations, + size=None, color=None, label=None): + """ + :param locations: Specifies the locations of the markers on + the map. + :type locations: list + + :param size: Specifies the size of the marker. + :type size: str + + :param color: Specifies a color of the marker. + :type color: str + + :param label: Specifies a single uppercase alphanumeric + character to be displaied on marker. + :type label: str + """ + + super(StaticMapMarker, self).__init__() + + if size: + self.params.append("size:%s" % size) + + if color: + self.params.append("color:%s" % color) + + if label: + if len(label) != 1 or (label.isalpha() and not label.isupper()) or not label.isalnum(): + raise ValueError("Marker label must be alphanumeric and uppercase.") + self.params.append("label:%s" % label) + + self.params.append(convert.location_list(locations)) + + +class StaticMapPath(StaticMapParam): + """Handles path parameters for Maps Static API.""" + + def __init__(self, points, + weight=None, color=None, + fillcolor=None, geodesic=None): + """ + :param points: Specifies the point through which the path + will be built. + :type points: list + + :param weight: Specifies the thickness of the path in pixels. + :type weight: int + + :param color: Specifies a color of the path. + :type color: str + + :param fillcolor: Indicates both that the path marks off a + polygonal area and specifies the fill color to use as an + overlay within that area. + :type fillcolor: str + + :param geodesic: Indicates that the requested path should be + interpreted as a geodesic line that follows the curvature + of the earth. + :type geodesic: bool + """ + + super(StaticMapPath, self).__init__() + + if weight: + self.params.append("weight:%s" % weight) + + if color: + self.params.append("color:%s" % color) + + if fillcolor: + self.params.append("fillcolor:%s" % fillcolor) + + if geodesic: + self.params.append("geodesic:%s" % geodesic) + + self.params.append(convert.location_list(points)) + + +def static_map(client, size, + center=None, zoom=None, scale=None, + format=None, maptype=None, language=None, region=None, + markers=None, path=None, visible=None, style=None): + """ + Downloads a map image from the Maps Static API. + + See https://developers.google.com/maps/documentation/maps-static/intro + for more info, including more detail for each parameter below. + + :param size: Defines the rectangular dimensions of the map image. + :type param: int or list + + :param center: Defines the center of the map, equidistant from all edges + of the map. + :type center: dict or list or string + + :param zoom: Defines the zoom level of the map, which determines the + magnification level of the map. + :type zoom: int + + :param scale: Affects the number of pixels that are returned. + :type scale: int + + :param format: Defines the format of the resulting image. + :type format: string + + :param maptype: defines the type of map to construct. There are several + possible maptype values, including roadmap, satellite, hybrid, + and terrain. + :type maptype: string + + :param language: defines the language to use for display of labels on + map tiles. + :type language: string + + :param region: defines the appropriate borders to display, based on + geo-political sensitivities. + :type region: string + + :param markers: define one or more markers to attach to the image at + specified locations. + :type markers: StaticMapMarker + + :param path: defines a single path of two or more connected points to + overlay on the image at specified locations. + :type path: StaticMapPath + + :param visible: specifies one or more locations that should remain visible + on the map, though no markers or other indicators will be displayed. + :type visible: list of dict + + :param style: defines a custom style to alter the presentation of + a specific feature (roads, parks, and other features) of the map. + :type style: list of dict + + :rtype: iterator containing the raw image data, which typically can be + used to save an image file locally. For example: + + .. code-block:: python + + f = open(local_filename, 'wb') + for chunk in client.static_map(size=(400, 400), + center=(52.520103, 13.404871), + zoom=15): + if chunk: + f.write(chunk) + f.close() + """ + + params = {"size": convert.size(size)} + + if not markers: + if not (center or zoom is not None): + raise ValueError( + "both center and zoom are required" + "when markers is not specifed" + ) + + if center: + params["center"] = convert.latlng(center) + + if zoom is not None: + params["zoom"] = zoom + + if scale is not None: + params["scale"] = scale + + if format: + if format not in MAPS_IMAGE_FORMATS: + raise ValueError("Invalid image format") + params['format'] = format + + if maptype: + if maptype not in MAPS_MAP_TYPES: + raise ValueError("Invalid maptype") + params["maptype"] = maptype + + if language: + params["language"] = language + + if region: + params["region"] = region + + if markers: + params["markers"] = markers + + if path: + params["path"] = path + + if visible: + params["visible"] = convert.location_list(visible) + + if style: + params["style"] = convert.components(style) + + response = client._request( + "/maps/api/staticmap", + params, + extract_body=lambda response: response, + requests_kwargs={"stream": True}, + ) + return response.iter_content() diff --git a/googlemaps/places.py b/googlemaps/places.py new file mode 100644 index 00000000..269a17fa --- /dev/null +++ b/googlemaps/places.py @@ -0,0 +1,707 @@ +# +# Copyright 2015 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Performs requests to the Google Places API.""" +import warnings + +from googlemaps import convert + + +PLACES_FIND_FIELDS_BASIC = {"business_status", + "formatted_address", + "geometry", + "geometry/location", + "geometry/location/lat", + "geometry/location/lng", + "geometry/viewport", + "geometry/viewport/northeast", + "geometry/viewport/northeast/lat", + "geometry/viewport/northeast/lng", + "geometry/viewport/southwest", + "geometry/viewport/southwest/lat", + "geometry/viewport/southwest/lng", + "icon", + "name", + "permanently_closed", + "photos", + "place_id", + "plus_code", + "types",} + +PLACES_FIND_FIELDS_CONTACT = {"opening_hours"} + +PLACES_FIND_FIELDS_ATMOSPHERE = {"price_level", "rating", "user_ratings_total"} + +PLACES_FIND_FIELDS = ( + PLACES_FIND_FIELDS_BASIC + ^ PLACES_FIND_FIELDS_CONTACT + ^ PLACES_FIND_FIELDS_ATMOSPHERE +) + +PLACES_DETAIL_FIELDS_BASIC = { + "address_component", + "adr_address", + "business_status", + "formatted_address", + "geometry", + "geometry/location", + "geometry/location/lat", + "geometry/location/lng", + "geometry/viewport", + "geometry/viewport/northeast", + "geometry/viewport/northeast/lat", + "geometry/viewport/northeast/lng", + "geometry/viewport/southwest", + "geometry/viewport/southwest/lat", + "geometry/viewport/southwest/lng", + "icon", + "name", + "permanently_closed", + "photo", + "place_id", + "plus_code", + "type", + "url", + "utc_offset", + "vicinity", + "wheelchair_accessible_entrance" +} + +PLACES_DETAIL_FIELDS_CONTACT = { + "formatted_phone_number", + "international_phone_number", + "opening_hours", + "current_opening_hours", + "secondary_opening_hours", + "website", +} + +PLACES_DETAIL_FIELDS_ATMOSPHERE = { + "curbside_pickup", + "delivery", + "dine_in", + "editorial_summary", + "price_level", + "rating", + "reservable", + "review", # prefer "reviews" to match API documentation + "reviews", + "serves_beer", + "serves_breakfast", + "serves_brunch", + "serves_dinner", + "serves_lunch", + "serves_vegetarian_food", + "serves_wine", + "takeout", + "user_ratings_total" +} + +PLACES_DETAIL_FIELDS = ( + PLACES_DETAIL_FIELDS_BASIC + ^ PLACES_DETAIL_FIELDS_CONTACT + ^ PLACES_DETAIL_FIELDS_ATMOSPHERE +) + +DEPRECATED_FIELDS = {"permanently_closed", "review"} +DEPRECATED_FIELDS_MESSAGE = ( + "Fields, %s, are deprecated. " + "Read more at https://developers.google.com/maps/deprecations." +) + + +def find_place( + client, input, input_type, fields=None, location_bias=None, language=None +): + """ + A Find Place request takes a text input, and returns a place. + The text input can be any kind of Places data, for example, + a name, address, or phone number. + + :param input: The text input specifying which place to search for (for + example, a name, address, or phone number). + :type input: string + + :param input_type: The type of input. This can be one of either 'textquery' + or 'phonenumber'. + :type input_type: string + + :param fields: The fields specifying the types of place data to return. For full details see: + https://developers.google.com/places/web-service/search#FindPlaceRequests + :type fields: list + + :param location_bias: Prefer results in a specified area, by specifying + either a radius plus lat/lng, or two lat/lng pairs + representing the points of a rectangle. See: + https://developers.google.com/places/web-service/search#FindPlaceRequests + :type location_bias: string + + :param language: The language in which to return results. + :type language: string + + :rtype: result dict with the following keys: + status: status code + candidates: list of places + """ + params = {"input": input, "inputtype": input_type} + + if input_type != "textquery" and input_type != "phonenumber": + raise ValueError( + "Valid values for the `input_type` param for " + "`find_place` are 'textquery' or 'phonenumber', " + "the given value is invalid: '%s'" % input_type + ) + + if fields: + deprecated_fields = set(fields) & DEPRECATED_FIELDS + if deprecated_fields: + warnings.warn( + DEPRECATED_FIELDS_MESSAGE % str(list(deprecated_fields)), + DeprecationWarning, + ) + + invalid_fields = set(fields) - PLACES_FIND_FIELDS + if invalid_fields: + raise ValueError( + "Valid values for the `fields` param for " + "`find_place` are '%s', these given field(s) " + "are invalid: '%s'" + % ("', '".join(PLACES_FIND_FIELDS), "', '".join(invalid_fields)) + ) + params["fields"] = convert.join_list(",", fields) + + if location_bias: + valid = ["ipbias", "point", "circle", "rectangle"] + if location_bias.split(":")[0] not in valid: + raise ValueError("location_bias should be prefixed with one of: %s" % valid) + params["locationbias"] = location_bias + if language: + params["language"] = language + + return client._request("/maps/api/place/findplacefromtext/json", params) + + +def places( + client, + query=None, + location=None, + radius=None, + language=None, + min_price=None, + max_price=None, + open_now=False, + type=None, + region=None, + page_token=None, +): + """ + Places search. + + :param query: The text string on which to search, for example: "restaurant". + :type query: string + + :param location: The latitude/longitude value for which you wish to obtain the + closest, human-readable address. + :type location: string, dict, list, or tuple + + :param radius: Distance in meters within which to bias results. + :type radius: int + + :param language: The language in which to return results. + :type language: string + + :param min_price: Restricts results to only those places with no less than + this price level. Valid values are in the range from 0 (most affordable) + to 4 (most expensive). + :type min_price: int + + :param max_price: Restricts results to only those places with no greater + than this price level. Valid values are in the range from 0 (most + affordable) to 4 (most expensive). + :type max_price: int + + :param open_now: Return only those places that are open for business at + the time the query is sent. + :type open_now: bool + + :param type: Restricts the results to places matching the specified type. + The full list of supported types is available here: + https://developers.google.com/places/supported_types + :type type: string + + :param region: The region code, optional parameter. + See more @ https://developers.google.com/places/web-service/search + :type region: string + + :param page_token: Token from a previous search that when provided will + returns the next page of results for the same search. + :type page_token: string + + :rtype: result dict with the following keys: + results: list of places + html_attributions: set of attributions which must be displayed + next_page_token: token for retrieving the next page of results + """ + return _places( + client, + "text", + query=query, + location=location, + radius=radius, + language=language, + min_price=min_price, + max_price=max_price, + open_now=open_now, + type=type, + region=region, + page_token=page_token, + ) + + +def places_nearby( + client, + location=None, + radius=None, + keyword=None, + language=None, + min_price=None, + max_price=None, + name=None, + open_now=False, + rank_by=None, + type=None, + page_token=None, +): + """ + Performs nearby search for places. + + :param location: The latitude/longitude value for which you wish to obtain the + closest, human-readable address. + :type location: string, dict, list, or tuple + + :param radius: Distance in meters within which to bias results. + :type radius: int + + :param region: The region code, optional parameter. + See more @ https://developers.google.com/places/web-service/search + :type region: string + + :param keyword: A term to be matched against all content that Google has + indexed for this place. + :type keyword: string + + :param language: The language in which to return results. + :type language: string + + :param min_price: Restricts results to only those places with no less than + this price level. Valid values are in the range from 0 + (most affordable) to 4 (most expensive). + :type min_price: int + + :param max_price: Restricts results to only those places with no greater + than this price level. Valid values are in the range + from 0 (most affordable) to 4 (most expensive). + :type max_price: int + + :param name: One or more terms to be matched against the names of places. + :type name: string or list of strings + + :param open_now: Return only those places that are open for business at + the time the query is sent. + :type open_now: bool + + :param rank_by: Specifies the order in which results are listed. + Possible values are: prominence (default), distance + :type rank_by: string + + :param type: Restricts the results to places matching the specified type. + The full list of supported types is available here: + https://developers.google.com/places/supported_types + :type type: string + + :param page_token: Token from a previous search that when provided will + returns the next page of results for the same search. + :type page_token: string + + :rtype: result dict with the following keys: + status: status code + results: list of places + html_attributions: set of attributions which must be displayed + next_page_token: token for retrieving the next page of results + + """ + if not location and not page_token: + raise ValueError("either a location or page_token arg is required") + if rank_by == "distance": + if not (keyword or name or type): + raise ValueError( + "either a keyword, name, or type arg is required " + "when rank_by is set to distance" + ) + elif radius is not None: + raise ValueError( + "radius cannot be specified when rank_by is set to " "distance" + ) + + return _places( + client, + "nearby", + location=location, + radius=radius, + keyword=keyword, + language=language, + min_price=min_price, + max_price=max_price, + name=name, + open_now=open_now, + rank_by=rank_by, + type=type, + page_token=page_token, + ) + + +def _places( + client, + url_part, + query=None, + location=None, + radius=None, + keyword=None, + language=None, + min_price=0, + max_price=4, + name=None, + open_now=False, + rank_by=None, + type=None, + region=None, + page_token=None, +): + """ + Internal handler for ``places`` and ``places_nearby``. + See each method's docs for arg details. + """ + + params = {"minprice": min_price, "maxprice": max_price} + + if query: + params["query"] = query + if location: + params["location"] = convert.latlng(location) + if radius: + params["radius"] = radius + if keyword: + params["keyword"] = keyword + if language: + params["language"] = language + if name: + params["name"] = convert.join_list(" ", name) + if open_now: + params["opennow"] = "true" + if rank_by: + params["rankby"] = rank_by + if type: + params["type"] = type + if region: + params["region"] = region + if page_token: + params["pagetoken"] = page_token + + url = "/maps/api/place/%ssearch/json" % url_part + return client._request(url, params) + + +def place( + client, + place_id, + session_token=None, + fields=None, + language=None, + reviews_no_translations=False, + reviews_sort="most_relevant", +): + """ + Comprehensive details for an individual place. + + :param place_id: A textual identifier that uniquely identifies a place, + returned from a Places search. + :type place_id: string + + :param session_token: A random string which identifies an autocomplete + session for billing purposes. + :type session_token: string + + :param fields: The fields specifying the types of place data to return, + separated by a comma. For full details see: + https://cloud.google.com/maps-platform/user-guide/product-changes/#places + :type input: list + + :param language: The language in which to return results. + :type language: string + + :param reviews_no_translations: Specify reviews_no_translations=True to disable translation of reviews; reviews_no_translations=False (default) enables translation of reviews. + :type reviews_no_translations: bool + + :param reviews_sort: The sorting method to use when returning reviews. + Can be set to most_relevant (default) or newest. + :type reviews_sort: string + + :rtype: result dict with the following keys: + result: dict containing place details + html_attributions: set of attributions which must be displayed + """ + params = {"placeid": place_id} + + if fields: + deprecated_fields = set(fields) & DEPRECATED_FIELDS + if deprecated_fields: + warnings.warn( + DEPRECATED_FIELDS_MESSAGE % str(list(deprecated_fields)), + DeprecationWarning, + ) + + invalid_fields = set(fields) - PLACES_DETAIL_FIELDS + if invalid_fields: + raise ValueError( + "Valid values for the `fields` param for " + "`place` are '%s', these given field(s) " + "are invalid: '%s'" + % ("', '".join(PLACES_DETAIL_FIELDS), "', '".join(invalid_fields)) + ) + params["fields"] = convert.join_list(",", fields) + + if language: + params["language"] = language + if session_token: + params["sessiontoken"] = session_token + if reviews_no_translations: + params["reviews_no_translations"] = "true" + if reviews_sort: + params["reviews_sort"] = reviews_sort + + return client._request("/maps/api/place/details/json", params) + + +def places_photo(client, photo_reference, max_width=None, max_height=None): + """ + Downloads a photo from the Places API. + + :param photo_reference: A string identifier that uniquely identifies a + photo, as provided by either a Places search or Places detail request. + :type photo_reference: string + + :param max_width: Specifies the maximum desired width, in pixels. + :type max_width: int + + :param max_height: Specifies the maximum desired height, in pixels. + :type max_height: int + + :rtype: iterator containing the raw image data, which typically can be + used to save an image file locally. For example: + + .. code-block:: python + + f = open(local_filename, 'wb') + for chunk in client.places_photo(photo_reference, max_width=100): + if chunk: + f.write(chunk) + f.close() + """ + + if not (max_width or max_height): + raise ValueError("a max_width or max_height arg is required") + + params = {"photoreference": photo_reference} + + if max_width: + params["maxwidth"] = max_width + if max_height: + params["maxheight"] = max_height + + # "extract_body" and "stream" args here are used to return an iterable + # response containing the image file data, rather than converting from + # json. + response = client._request( + "/maps/api/place/photo", + params, + extract_body=lambda response: response, + requests_kwargs={"stream": True}, + ) + return response.iter_content() + + +def places_autocomplete( + client, + input_text, + session_token=None, + offset=None, + origin=None, + location=None, + radius=None, + language=None, + types=None, + components=None, + strict_bounds=False, +): + """ + Returns Place predictions given a textual search string and optional + geographic bounds. + + :param input_text: The text string on which to search. + :type input_text: string + + :param session_token: A random string which identifies an autocomplete + session for billing purposes. + :type session_token: string + + :param offset: The position, in the input term, of the last character + that the service uses to match predictions. For example, + if the input is 'Google' and the offset is 3, the + service will match on 'Goo'. + :type offset: int + + :param origin: The origin point from which to calculate straight-line distance + to the destination (returned as distance_meters). + If this value is omitted, straight-line distance will + not be returned. + :type origin: string, dict, list, or tuple + + :param location: The latitude/longitude value for which you wish to obtain the + closest, human-readable address. + :type location: string, dict, list, or tuple + + :param radius: Distance in meters within which to bias results. + :type radius: int + + :param language: The language in which to return results. + :type language: string + + :param types: Restricts the results to places matching the specified type. + The full list of supported types is available here: + https://developers.google.com/places/web-service/autocomplete#place_types + :type types: string + + :param components: A component filter for which you wish to obtain a geocode. + Currently, you can use components to filter by up to 5 countries for + example: ``{'country': ['US', 'AU']}`` + :type components: dict + + :param strict_bounds: Returns only those places that are strictly within + the region defined by location and radius. + :type strict_bounds: bool + + :rtype: list of predictions + + """ + return _autocomplete( + client, + "", + input_text, + session_token=session_token, + offset=offset, + origin=origin, + location=location, + radius=radius, + language=language, + types=types, + components=components, + strict_bounds=strict_bounds, + ) + + +def places_autocomplete_query( + client, input_text, offset=None, location=None, radius=None, language=None +): + """ + Returns Place predictions given a textual search query, such as + "pizza near New York", and optional geographic bounds. + + :param input_text: The text query on which to search. + :type input_text: string + + :param offset: The position, in the input term, of the last character + that the service uses to match predictions. For example, if the input + is 'Google' and the offset is 3, the service will match on 'Goo'. + :type offset: int + + :param location: The latitude/longitude value for which you wish to obtain the + closest, human-readable address. + :type location: string, dict, list, or tuple + + :param radius: Distance in meters within which to bias results. + :type radius: number + + :param language: The language in which to return results. + :type language: string + + :rtype: list of predictions + """ + return _autocomplete( + client, + "query", + input_text, + offset=offset, + location=location, + radius=radius, + language=language, + ) + + +def _autocomplete( + client, + url_part, + input_text, + session_token=None, + offset=None, + origin=None, + location=None, + radius=None, + language=None, + types=None, + components=None, + strict_bounds=False, +): + """ + Internal handler for ``autocomplete`` and ``autocomplete_query``. + See each method's docs for arg details. + """ + + params = {"input": input_text} + + if session_token: + params["sessiontoken"] = session_token + if offset: + params["offset"] = offset + if origin: + params["origin"] = convert.latlng(origin) + if location: + params["location"] = convert.latlng(location) + if radius: + params["radius"] = radius + if language: + params["language"] = language + if types: + params["types"] = types + if components: + if len(components) != 1 or list(components.keys())[0] != "country": + raise ValueError("Only country components are supported") + params["components"] = convert.components(components) + if strict_bounds: + params["strictbounds"] = "true" + + url = "/maps/api/place/%sautocomplete/json" % url_part + return client._request(url, params).get("predictions", []) diff --git a/googlemaps/roads.py b/googlemaps/roads.py index 0e659a9f..edfb8ecb 100644 --- a/googlemaps/roads.py +++ b/googlemaps/roads.py @@ -20,8 +20,10 @@ import googlemaps from googlemaps import convert + _ROADS_BASE_URL = "https://roads.googleapis.com" + def snap_to_roads(client, path, interpolate=False): """Snaps a path to the most likely roads travelled. @@ -29,44 +31,57 @@ def snap_to_roads(client, path, interpolate=False): set of data with the points snapped to the most likely roads the vehicle was traveling along. - :param path: The path to be snapped. A list of latitude/longitude tuples. - :type path: list + :param path: The path to be snapped. + :type path: a single location, or a list of locations, where a + location is a string, dict, list, or tuple :param interpolate: Whether to interpolate a path to include all points - forming the full road-geometry. When true, additional interpolated - points will also be returned, resulting in a path that smoothly - follows the geometry of the road, even around corners and through - tunnels. Interpolated paths may contain more points than the - original path. + forming the full road-geometry. When true, additional interpolated + points will also be returned, resulting in a path that smoothly follows + the geometry of the road, even around corners and through tunnels. + Interpolated paths may contain more points than the original path. :type interpolate: bool :rtype: A list of snapped points. """ - if type(path) is tuple: - path = [path] - - path = convert.join_list("|", - [convert.latlng(k) for k in convert.as_list(path)]) - - params = { - "path": path - } + params = {"path": convert.location_list(path)} if interpolate: params["interpolate"] = "true" - return client._get("/v1/snapToRoads", params, + return client._request("/v1/snapToRoads", params, base_url=_ROADS_BASE_URL, accepts_clientid=False, - extract_body=_roads_extract)["snappedPoints"] + extract_body=_roads_extract).get("snappedPoints", []) +def nearest_roads(client, points): + """Find the closest road segments for each point + + Takes up to 100 independent coordinates, and returns the closest road + segment for each point. The points passed do not need to be part of a + continuous path. + + :param points: The points for which the nearest road segments are to be + located. + :type points: a single location, or a list of locations, where a + location is a string, dict, list, or tuple + + :rtype: A list of snapped points. + """ + + params = {"points": convert.location_list(points)} + + return client._request("/v1/nearestRoads", params, + base_url=_ROADS_BASE_URL, + accepts_clientid=False, + extract_body=_roads_extract).get("snappedPoints", []) def speed_limits(client, place_ids): """Returns the posted speed limit (in km/h) for given road segments. :param place_ids: The Place ID of the road segment. Place IDs are returned - by the snap_to_roads function. You can pass up to 100 Place IDs. + by the snap_to_roads function. You can pass up to 100 Place IDs. :type place_ids: str or list :rtype: list of speed limits. @@ -74,10 +89,10 @@ def speed_limits(client, place_ids): params = [("placeId", place_id) for place_id in convert.as_list(place_ids)] - return client._get("/v1/speedLimits", params, + return client._request("/v1/speedLimits", params, base_url=_ROADS_BASE_URL, accepts_clientid=False, - extract_body=_roads_extract)["speedLimits"] + extract_body=_roads_extract).get("speedLimits", []) def snapped_speed_limits(client, path): @@ -86,25 +101,16 @@ def snapped_speed_limits(client, path): The provided points will first be snapped to the most likely roads the vehicle was traveling along. - :param path: The path of points to be snapped. A list of (or single) - latitude/longitude tuples. - :type path: list or tuple + :param path: The path of points to be snapped. + :type path: a single location, or a list of locations, where a + location is a string, dict, list, or tuple - :rtype: a dict with both a list of speed limits and a list of the snapped - points. + :rtype: dict with a list of speed limits and a list of the snapped points. """ - if type(path) is tuple: - path = [path] - - path = convert.join_list("|", - [convert.latlng(k) for k in convert.as_list(path)]) - - params = { - "path": path - } + params = {"path": convert.location_list(path)} - return client._get("/v1/speedLimits", params, + return client._request("/v1/speedLimits", params, base_url=_ROADS_BASE_URL, accepts_clientid=False, extract_body=_roads_extract) @@ -127,12 +133,10 @@ def _roads_extract(resp): status = error["status"] if status == "RESOURCE_EXHAUSTED": - raise googlemaps.exceptions._RetriableRequest() + raise googlemaps.exceptions._OverQueryLimit(status, + error.get("message")) - if "message" in error: - raise googlemaps.exceptions.ApiError(status, error["message"]) - else: - raise googlemaps.exceptions.ApiError(status) + raise googlemaps.exceptions.ApiError(status, error.get("message")) if resp.status_code != 200: raise googlemaps.exceptions.HTTPError(resp.status_code) diff --git a/googlemaps/timezone.py b/googlemaps/timezone.py index 593a76e7..0b6370dc 100644 --- a/googlemaps/timezone.py +++ b/googlemaps/timezone.py @@ -28,13 +28,13 @@ def timezone(client, location, timestamp=None, language=None): :param location: The latitude/longitude value representing the location to look up. - :type location: dict or tuple + :type location: string, dict, list, or tuple :param timestamp: Timestamp specifies the desired time as seconds since midnight, January 1, 1970 UTC. The Time Zone API uses the timestamp to determine whether or not Daylight Savings should be applied. Times before 1970 can be expressed as negative values. Optional. Defaults to - ``datetime.now()``. + ``datetime.utcnow()``. :type timestamp: int or datetime.datetime :param language: The language in which to return results. @@ -43,16 +43,12 @@ def timezone(client, location, timestamp=None, language=None): :rtype: dict """ - location = convert.latlng(location) - - timestamp = convert.time(timestamp or datetime.now()) - params = { - "location": location, - "timestamp": timestamp + "location": convert.latlng(location), + "timestamp": convert.time(timestamp or datetime.utcnow()) } if language: params["language"] = language - return client._get( "/maps/api/timezone/json", params) + return client._request( "/maps/api/timezone/json", params) diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..e5b92961 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,81 @@ +# Copyright 2022 Google LLC +# +# 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. + +import nox + +SUPPORTED_PY_VERSIONS = ["3.7", "3.8", "3.9", "3.10"] + + +def _install_dev_packages(session): + session.install("-e", ".") + + +def _install_test_dependencies(session): + session.install("pytest") + session.install("pytest-cov") + session.install("responses") + + +def _install_doc_dependencies(session): + session.install("sphinx") + + +@nox.session(python=SUPPORTED_PY_VERSIONS) +def tests(session): + _install_dev_packages(session) + _install_test_dependencies(session) + + session.install("pytest") + session.run("pytest") + + session.notify("cover") + + +@nox.session +def cover(session): + """Coverage analysis.""" + session.install("coverage") + session.install("codecov") + session.run("coverage", "report", "--show-missing") + session.run("codecov") + session.run("coverage", "erase") + + +@nox.session(python="3.7") +def docs(session): + _install_dev_packages(session) + _install_doc_dependencies(session) + + session.run("rm", "-rf", "docs/_build", external=True) + + sphinx_args = [ + "-a", + "-E", + "-b", + "html", + "-d", + "docs/_build/doctrees", + "docs", + "docs/_build/html", + ] + + sphinx_cmd = "sphinx-build" + + session.run(sphinx_cmd, *sphinx_args) + + +@nox.session() +def distribution(session): + session.run("bash", ".github/scripts/distribution.sh", external=True) + session.run("python", "-c", "import googlemaps") diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..56320971 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,12 @@ +[tool:pytest] +addopts = -rsxX --cov=googlemaps --cov-report= + +[coverage:run] +omit = + tests/* + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + raise NotImplementedError \ No newline at end of file diff --git a/setup.py b/setup.py index 672c09e8..df9f32f1 100644 --- a/setup.py +++ b/setup.py @@ -1,42 +1,52 @@ -import sys +# Copyright 2022 Google LLC +# +# 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. +from setuptools import setup -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +requirements = ["requests>=2.20.0,<3.0"] -if sys.version_info <= (2, 4): - error = 'Requires Python Version 2.5 or above... exiting.' - print >> sys.stderr, error - sys.exit(1) +with open("README.md") as f: + readme = f.read() +with open("CHANGELOG.md") as f: + changelog = f.read() -requirements = [ - 'requests<=2.6', -] -setup(name='googlemaps', - version='2.3-dev', - description='Python client library for Google Maps API Web Services', - scripts=[], - url='https://github.com/googlemaps/google-maps-services-python', - packages=['googlemaps'], - license='Apache 2.0', - platforms='Posix; MacOS X; Windows', - setup_requires=requirements, - install_requires=requirements, - classifiers=['Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.4', - 'Topic :: Internet', - ] - ) +setup( + name="googlemaps", + version="4.10.0", + description="Python client library for Google Maps Platform", + long_description=readme + changelog, + long_description_content_type="text/markdown", + scripts=[], + url="https://github.com/googlemaps/google-maps-services-python", + packages=["googlemaps"], + license="Apache 2.0", + platforms="Posix; MacOS X; Windows", + setup_requires=requirements, + install_requires=requirements, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Topic :: Internet", + ], + python_requires='>=3.5' +) diff --git a/test/test_client.py b/test/test_client.py deleted file mode 100644 index 5489e547..00000000 --- a/test/test_client.py +++ /dev/null @@ -1,218 +0,0 @@ -# -# Copyright 2014 Google Inc. All rights reserved. -# -# -# 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. -# - - -"""Tests for client module.""" - -import responses -import time - -import googlemaps -from googlemaps import client as _client -import test as _test - -class ClientTest(_test.TestCase): - - def test_no_api_key(self): - with self.assertRaises(Exception): - client = googlemaps.Client() - client.directions("Sydney", "Melbourne") - - def test_invalid_api_key(self): - with self.assertRaises(Exception): - client = googlemaps.Client(key="Invalid key.") - client.directions("Sydney", "Melbourne") - - def test_urlencode(self): - # See GH #72. - encoded_params = _client.urlencode_params([("address", "=Sydney ~")]) - self.assertEqual("address=%3DSydney+~", encoded_params) - - @responses.activate - def test_queries_per_second(self): - # This test assumes that the time to run a mocked query is - # relatively small, eg a few milliseconds. We define a rate of - # 3 queries per second, and run double that, which should take at - # least 1 second but no more than 2. - queries_per_second = 3 - query_range = range(queries_per_second * 2) - for _ in query_range: - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - body='{"status":"OK","results":[]}', - status=200, - content_type="application/json") - client = googlemaps.Client(key="AIzaasdf", - queries_per_second=queries_per_second) - start = time.time() - for _ in query_range: - client.geocode("Sesame St.") - end = time.time() - self.assertTrue(start + 1 < end < start + 2) - - @responses.activate - def test_key_sent(self): - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - body='{"status":"OK","results":[]}', - status=200, - content_type="application/json") - - client = googlemaps.Client(key="AIzaasdf") - client.geocode("Sesame St.") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual("https://maps.googleapis.com/maps/api/geocode/json?" - "key=AIzaasdf&address=Sesame+St.", - responses.calls[0].request.url) - - def test_hmac(self): - """ - From http://en.wikipedia.org/wiki/Hash-based_message_authentication_code - - HMAC_SHA1("key", "The quick brown fox jumps over the lazy dog") - = 0xde7c9b85b8b78aa6bc8a7a36f70a90701c9db4d9 - """ - - message = "The quick brown fox jumps over the lazy dog" - key = "a2V5" # "key" -> base64 - signature = "3nybhbi3iqa8ino29wqQcBydtNk=" - - self.assertEqual(signature, _client.sign_hmac(key, message)) - - @responses.activate - def test_url_signed(self): - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - body='{"status":"OK","results":[]}', - status=200, - content_type="application/json") - - client = googlemaps.Client(client_id="foo", client_secret="a2V5") - client.geocode("Sesame St.") - - self.assertEqual(1, len(responses.calls)) - - # Check ordering of parameters. - self.assertIn("address=Sesame+St.&client=foo&signature", - responses.calls[0].request.url) - self.assertURLEqual("https://maps.googleapis.com/maps/api/geocode/json?" - "address=Sesame+St.&client=foo&" - "signature=fxbWUIcNPZSekVOhp2ul9LW5TpY=", - responses.calls[0].request.url) - - @responses.activate - def test_ua_sent(self): - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - body='{"status":"OK","results":[]}', - status=200, - content_type="application/json") - - client = googlemaps.Client(key="AIzaasdf") - client.geocode("Sesame St.") - - self.assertEqual(1, len(responses.calls)) - user_agent = responses.calls[0].request.headers["User-Agent"] - self.assertTrue(user_agent.startswith("GoogleGeoApiClientPython")) - - @responses.activate - def test_retry(self): - class request_callback: - def __init__(self): - self.first_req = True - - def __call__(self, req): - if self.first_req: - self.first_req = False - return (200, {}, '{"status":"OVER_QUERY_LIMIT"}') - return (200, {}, '{"status":"OK","results":[]}') - - responses.add_callback(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - content_type='application/json', - callback=request_callback()) - - client = googlemaps.Client(key="AIzaasdf") - client.geocode("Sesame St.") - - self.assertEqual(2, len(responses.calls)) - self.assertEqual(responses.calls[0].request.url, responses.calls[1].request.url) - - @responses.activate - def test_transport_error(self): - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - status=404, - content_type='application/json') - - client = googlemaps.Client(key="AIzaasdf") - with self.assertRaises(googlemaps.exceptions.HTTPError) as e: - client.geocode("Foo") - - self.assertEqual(e.exception.status_code, 404) - - @responses.activate - def test_host_override(self): - responses.add(responses.GET, - "https://foo.com/bar", - body='{"status":"OK","results":[]}', - status=200, - content_type="application/json") - - client = googlemaps.Client(key="AIzaasdf") - client._get("/bar", {}, base_url="https://foo.com") - - self.assertEqual(1, len(responses.calls)) - - @responses.activate - def test_custom_extract(self): - def custom_extract(resp): - return resp.json() - - responses.add(responses.GET, - "https://maps.googleapis.com/bar", - body='{"error":"errormessage"}', - status=403, - content_type="application/json") - - client = googlemaps.Client(key="AIzaasdf") - b = client._get("/bar", {}, extract_body=custom_extract) - self.assertEqual(1, len(responses.calls)) - self.assertEqual("errormessage", b["error"]) - - @responses.activate - def test_retry_intermittent(self): - class request_callback: - def __init__(self): - self.first_req = True - - def __call__(self, req): - if self.first_req: - self.first_req = False - return (500, {}, 'Internal Server Error.') - return (200, {}, '{"status":"OK","results":[]}') - - responses.add_callback(responses.GET, - "https://maps.googleapis.com/maps/api/geocode/json", - content_type="application/json", - callback=request_callback()) - - client = googlemaps.Client(key="AIzaasdf") - client.geocode("Sesame St.") - - self.assertEqual(2, len(responses.calls)) diff --git a/test/test_convert.py b/test/test_convert.py deleted file mode 100644 index d1941915..00000000 --- a/test/test_convert.py +++ /dev/null @@ -1,136 +0,0 @@ -# -# Copyright 2014 Google Inc. All rights reserved. -# -# -# 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. -# - -"""Tests for the convert module.""" - -import datetime -import unittest - -from googlemaps import convert - - -class ConvertTest(unittest.TestCase): - - def test_latlng(self): - ll = {"lat": 1, "lng": 2} - self.assertEqual("1.000000,2.000000", convert.latlng(ll)) - - ll = [1, 2] - self.assertEqual("1.000000,2.000000", convert.latlng(ll)) - - ll = (1, 2) - self.assertEqual("1.000000,2.000000", convert.latlng(ll)) - - with self.assertRaises(TypeError): - convert.latlng(1) - - with self.assertRaises(TypeError): - convert.latlng("test") - - def test_join_list(self): - self.assertEqual("asdf", convert.join_list("|", "asdf")) - - self.assertEqual("1,2,A", convert.join_list(",", ["1", "2", "A"])) - - self.assertEqual("", convert.join_list(",", [])) - - self.assertEqual("a,B", convert.join_list(",", ("a", "B"))) - - def test_as_list(self): - self.assertEqual([1], convert.as_list(1)) - - self.assertEqual([1, 2, 3], convert.as_list([1, 2, 3])) - - self.assertEqual(["string"], convert.as_list("string")) - - self.assertEqual((1, 2), convert.as_list((1, 2))) - - a_dict = {"a": 1} - self.assertEqual([a_dict], convert.as_list(a_dict)) - - def test_time(self): - self.assertEqual("1409810596", convert.time(1409810596)) - - dt = datetime.datetime.fromtimestamp(1409810596) - self.assertEqual("1409810596", convert.time(dt)) - - def test_components(self): - c = {"country": "US"} - self.assertEqual("country:US", convert.components(c)) - - c = {"country": "US", "foo": 1} - self.assertEqual("country:US|foo:1", convert.components(c)) - - with self.assertRaises(TypeError): - convert.components("test") - - with self.assertRaises(TypeError): - convert.components(1) - - with self.assertRaises(TypeError): - convert.components(("c", "b")) - - def test_bounds(self): - ne = {"lat": 1, "lng": 2} - sw = (3, 4) - b = {"northeast": ne, "southwest": sw} - self.assertEqual("3.000000,4.000000|1.000000,2.000000", - convert.bounds(b)) - - with self.assertRaises(TypeError): - convert.bounds("test") - - def test_polyline_decode(self): - syd_mel_route = ("rvumEis{y[`NsfA~tAbF`bEj^h{@{KlfA~eA~`AbmEghAt~D|e@j" - "lRpO~yH_\\v}LjbBh~FdvCxu@`nCplDbcBf_B|wBhIfhCnqEb~D~" - "jCn_EngApdEtoBbfClf@t_CzcCpoEr_Gz_DxmAphDjjBxqCviEf}" - "B|pEvsEzbE~qGfpExjBlqCx}BvmLb`FbrQdpEvkAbjDllD|uDldD" - "j`Ef|AzcEx_Gtm@vuI~xArwD`dArlFnhEzmHjtC~eDluAfkC|eAd" - "hGpJh}N_mArrDlr@h|HzjDbsAvy@~~EdTxpJje@jlEltBboDjJdv" - "KyZpzExrAxpHfg@pmJg[tgJuqBnlIarAh}DbN`hCeOf_IbxA~uFt" - "|A|xEt_ArmBcN|sB|h@b_DjOzbJ{RlxCcfAp~AahAbqG~Gr}AerA" - "`dCwlCbaFo]twKt{@bsG|}A~fDlvBvz@tw@rpD_r@rqB{PvbHek@" - "vsHlh@ptNtm@fkD[~xFeEbyKnjDdyDbbBtuA|~Br|Gx_AfxCt}Cj" - "nHv`Ew\\lnBdrBfqBraD|{BldBxpG|]jqC`mArcBv]rdAxgBzdEb" - "{InaBzyC}AzaEaIvrCzcAzsCtfD~qGoPfeEh]h`BxiB`e@`kBxfA" - "v^pyA`}BhkCdoCtrC~bCxhCbgEplKrk@tiAteBwAxbCwuAnnCc]b" - "{FjrDdjGhhGzfCrlDruBzSrnGhvDhcFzw@n{@zxAf}Fd{IzaDnbD" - "joAjqJjfDlbIlzAraBxrB}K~`GpuD~`BjmDhkBp{@r_AxCrnAjrC" - "x`AzrBj{B|r@~qBbdAjtDnvCtNzpHxeApyC|GlfM`fHtMvqLjuEt" - "lDvoFbnCt|@xmAvqBkGreFm~@hlHw|AltC}NtkGvhBfaJ|~@riAx" - "uC~gErwCttCzjAdmGuF`iFv`AxsJftD|nDr_QtbMz_DheAf~Buy@" - "rlC`i@d_CljC`gBr|H|nAf_Fh{G|mE~kAhgKviEpaQnu@zwAlrA`" - "G~gFnvItz@j{Cng@j{D{]`tEftCdcIsPz{DddE~}PlnE|dJnzG`e" - "G`mF|aJdqDvoAwWjzHv`H`wOtjGzeXhhBlxErfCf{BtsCjpEjtD|" - "}Aja@xnAbdDt|ErMrdFh{CzgAnlCnr@`wEM~mE`bA`uD|MlwKxmB" - "vuFlhB|sN`_@fvBp`CxhCt_@loDsS|eDlmChgFlqCbjCxk@vbGxm" - "CjbMba@rpBaoClcCk_DhgEzYdzBl\\vsA_JfGztAbShkGtEhlDzh" - "C~w@hnB{e@yF}`D`_Ayx@~vGqn@l}CafC") - - points = convert.decode_polyline(syd_mel_route) - self.assertAlmostEqual(-33.86746, points[0]["lat"]) - self.assertAlmostEqual(151.207090, points[0]["lng"]) - self.assertAlmostEqual(-37.814130, points[-1]["lat"]) - self.assertAlmostEqual(144.963180, points[-1]["lng"]) - - def test_polyline_round_trip(self): - test_polyline = ("gcneIpgxzRcDnBoBlEHzKjBbHlG`@`IkDxIi" - "KhKoMaLwTwHeIqHuAyGXeB~Ew@fFjAtIzExF") - - points = convert.decode_polyline(test_polyline) - actual_polyline = convert.encode_polyline(points) - self.assertEqual(test_polyline, actual_polyline) diff --git a/test/test_directions.py b/test/test_directions.py deleted file mode 100644 index 2de5e0bc..00000000 --- a/test/test_directions.py +++ /dev/null @@ -1,275 +0,0 @@ -# -# Copyright 2014 Google Inc. All rights reserved. -# -# -# 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. -# - -"""Tests for the directions module.""" - -from datetime import datetime -from datetime import timedelta -import responses -import time - -import googlemaps -import test as _test - -class DirectionsTest(_test.TestCase): - - def setUp(self): - self.key = 'AIzaasdf' - self.client = googlemaps.Client(self.key) - - @responses.activate - def test_simple_directions(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - # Simplest directions request. Driving directions by default. - routes = self.client.directions("Sydney", "Melbourne") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json' - '?origin=Sydney&destination=Melbourne&key=%s' % - self.key, - responses.calls[0].request.url) - - @responses.activate - def test_complex_request(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Sydney", "Melbourne", - mode="bicycling", - avoid=["highways", "tolls", "ferries"], - units="metric", - region="us") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Sydney&avoid=highways%%7Ctolls%%7Cferries&' - 'destination=Melbourne&mode=bicycling&key=%s' - '&units=metric®ion=us' % - self.key, - responses.calls[0].request.url) - - - def test_transit_without_time(self): - # With mode of transit, we need a departure_time or an - # arrival_time specified - with self.assertRaises(googlemaps.exceptions.ApiError): - self.client.directions("Sydney Town Hall", "Parramatta, NSW", - mode="transit") - - @responses.activate - def test_transit_with_departure_time(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - now = datetime.now() - routes = self.client.directions("Sydney Town Hall", "Parramatta, NSW", - mode="transit", - departure_time=now) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?origin=' - 'Sydney+Town+Hall&key=%s&destination=Parramatta%%2C+NSW&' - 'mode=transit&departure_time=%d' % - (self.key, time.mktime(now.timetuple())), - responses.calls[0].request.url) - - @responses.activate - def test_transit_with_arrival_time(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - an_hour_from_now = datetime.now() + timedelta(hours=1) - routes = self.client.directions("Sydney Town Hall", - "Parramatta, NSW", - mode="transit", - arrival_time=an_hour_from_now) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Sydney+Town+Hall&arrival_time=%d&' - 'destination=Parramatta%%2C+NSW&mode=transit&key=%s' % - (time.mktime(an_hour_from_now.timetuple()), self.key), - responses.calls[0].request.url) - - - def test_crazy_travel_mode(self): - with self.assertRaises(ValueError): - self.client.directions("48 Pirrama Road, Pyrmont, NSW", - "Sydney Town Hall", - mode="crawling") - - @responses.activate - def test_travel_mode_round_trip(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Town Hall, Sydney", - "Parramatta, NSW", - mode="bicycling") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Town+Hall%%2C+Sydney&destination=Parramatta%%2C+NSW&' - 'mode=bicycling&key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_brooklyn_to_queens_by_transit(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - now = datetime.now() - routes = self.client.directions("Brooklyn", - "Queens", - mode="transit", - departure_time=now) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Brooklyn&key=%s&destination=Queens&mode=transit&' - 'departure_time=%d' % (self.key, time.mktime(now.timetuple())), - responses.calls[0].request.url) - - @responses.activate - def test_boston_to_concord_via_charlestown_and_lexington(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Boston, MA", - "Concord, MA", - waypoints=["Charlestown, MA", - "Lexington, MA"]) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Boston%%2C+MA&destination=Concord%%2C+MA&' - 'waypoints=Charlestown%%2C+MA%%7CLexington%%2C+MA&' - 'key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_adelaide_wine_tour(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Adelaide, SA", - "Adelaide, SA", - waypoints=["Barossa Valley, SA", - "Clare, SA", - "Connawarra, SA", - "McLaren Vale, SA"], - optimize_waypoints=True) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Adelaide%%2C+SA&destination=Adelaide%%2C+SA&' - 'waypoints=optimize%%3Atrue%%7CBarossa+Valley%%2C+' - 'SA%%7CClare%%2C+SA%%7CConnawarra%%2C+SA%%7CMcLaren+' - 'Vale%%2C+SA&key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_toledo_to_madrid_in_spain(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Toledo", "Madrid", - region="es") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Toledo®ion=es&destination=Madrid&key=%s' % - self.key, - responses.calls[0].request.url) - - @responses.activate - def test_zero_results_returns_response(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"ZERO_RESULTS","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Toledo", "Madrid") - self.assertIsNotNone(routes) - self.assertEqual(0, len(routes)) - - @responses.activate - def test_language_parameter(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Toledo", "Madrid", - region="es", - language="es") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Toledo®ion=es&destination=Madrid&key=%s&' - 'language=es' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_alternatives(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/directions/json', - body='{"status":"OK","routes":[]}', - status=200, - content_type='application/json') - - routes = self.client.directions("Sydney Town Hall", - "Parramatta Town Hall", - alternatives=True) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/directions/json?' - 'origin=Sydney+Town+Hall&destination=Parramatta+Town+Hall&' - 'alternatives=true&key=%s' % self.key, - responses.calls[0].request.url) - diff --git a/test/test_distance_matrix.py b/test/test_distance_matrix.py deleted file mode 100644 index d0848f59..00000000 --- a/test/test_distance_matrix.py +++ /dev/null @@ -1,140 +0,0 @@ -# -# Copyright 2014 Google Inc. All rights reserved. -# -# -# 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. -# - -"""Tests for the distance matrix module.""" - -import googlemaps -import responses -import test as _test - -class DistanceMatrixTest(_test.TestCase): - - def setUp(self): - self.key = 'AIzaasdf' - self.client = googlemaps.Client(self.key) - - @responses.activate - def test_basic_params(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/distancematrix/json', - body='{"status":"OK","rows":[]}', - status=200, - content_type='application/json') - - origins = ["Perth, Australia", "Sydney, Australia", - "Melbourne, Australia", "Adelaide, Australia", - "Brisbane, Australia", "Darwin, Australia", - "Hobart, Australia", "Canberra, Australia"] - destinations = ["Uluru, Australia", - "Kakadu, Australia", - "Blue Mountains, Australia", - "Bungle Bungles, Australia", - "The Pinnacles, Australia"] - - matrix = self.client.distance_matrix(origins, destinations) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/distancematrix/json?' - 'key=%s&origins=Perth%%2C+Australia%%7CSydney%%2C+' - 'Australia%%7CMelbourne%%2C+Australia%%7CAdelaide%%2C+' - 'Australia%%7CBrisbane%%2C+Australia%%7CDarwin%%2C+' - 'Australia%%7CHobart%%2C+Australia%%7CCanberra%%2C+Australia&' - 'destinations=Uluru%%2C+Australia%%7CKakadu%%2C+Australia%%7C' - 'Blue+Mountains%%2C+Australia%%7CBungle+Bungles%%2C+Australia' - '%%7CThe+Pinnacles%%2C+Australia' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_mixed_params(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/distancematrix/json', - body='{"status":"OK","rows":[]}', - status=200, - content_type='application/json') - - origins = ["Bobcaygeon ON", [41.43206, -81.38992]] - destinations = [(43.012486, -83.6964149), - {"lat": 42.8863855, "lng": -78.8781627}] - - matrix = self.client.distance_matrix(origins, destinations) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/distancematrix/json?' - 'key=%s&origins=Bobcaygeon+ON%%7C41.432060%%2C-81.389920&' - 'destinations=43.012486%%2C-83.696415%%7C42.886386%%2C' - '-78.878163' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_all_params(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/distancematrix/json', - body='{"status":"OK","rows":[]}', - status=200, - content_type='application/json') - - origins = ["Perth, Australia", "Sydney, Australia", - "Melbourne, Australia", "Adelaide, Australia", - "Brisbane, Australia", "Darwin, Australia", - "Hobart, Australia", "Canberra, Australia"] - destinations = ["Uluru, Australia", - "Kakadu, Australia", - "Blue Mountains, Australia", - "Bungle Bungles, Australia", - "The Pinnacles, Australia"] - - matrix = self.client.distance_matrix(origins, destinations, - mode="driving", - language="en-AU", - avoid="tolls", - units="imperial") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/distancematrix/json?' - 'origins=Perth%%2C+Australia%%7CSydney%%2C+Australia%%7C' - 'Melbourne%%2C+Australia%%7CAdelaide%%2C+Australia%%7C' - 'Brisbane%%2C+Australia%%7CDarwin%%2C+Australia%%7CHobart%%2C+' - 'Australia%%7CCanberra%%2C+Australia&language=en-AU&' - 'avoid=tolls&mode=driving&key=%s&units=imperial&' - 'destinations=Uluru%%2C+Australia%%7CKakadu%%2C+Australia%%7C' - 'Blue+Mountains%%2C+Australia%%7CBungle+Bungles%%2C+Australia' - '%%7CThe+Pinnacles%%2C+Australia' % self.key, - responses.calls[0].request.url) - - - @responses.activate - def test_lang_param(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/distancematrix/json', - body='{"status":"OK","rows":[]}', - status=200, - content_type='application/json') - - origins = ["Vancouver BC", "Seattle"] - destinations = ["San Francisco", "Victoria BC"] - - matrix = self.client.distance_matrix(origins, destinations, - language="fr-FR", - mode="bicycling") - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/distancematrix/json?' - 'key=%s&language=fr-FR&mode=bicycling&' - 'origins=Vancouver+BC%%7CSeattle&' - 'destinations=San+Francisco%%7CVictoria+BC' % - self.key, - responses.calls[0].request.url) diff --git a/test/test_elevation.py b/test/test_elevation.py deleted file mode 100644 index 73b31a93..00000000 --- a/test/test_elevation.py +++ /dev/null @@ -1,101 +0,0 @@ -# -# Copyright 2014 Google Inc. All rights reserved. -# -# -# 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. -# - -"""Tests for the elevation module.""" - -import datetime -import responses - -import googlemaps -import test as _test - -class ElevationTest(_test.TestCase): - - def setUp(self): - self.key = 'AIzaasdf' - self.client = googlemaps.Client(self.key) - - @responses.activate - def test_elevation_single(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/elevation/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.elevation((40.714728, -73.998672)) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/elevation/json?' - 'locations=40.714728%%2C-73.998672&key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_elevation_single_list(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/elevation/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.elevation([(40.714728, -73.998672)]) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/elevation/json?' - 'locations=40.714728%%2C-73.998672&key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_elevation_multiple(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/elevation/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - locations = [(40.714728, -73.998672), (-34.397, 150.644)] - results = self.client.elevation(locations) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/elevation/json?' - 'locations=40.714728%%2C-73.998672%%7C-34.397000%%2C' - '150.644000&key=%s' % self.key, - responses.calls[0].request.url) - - def test_elevation_along_path_single(self): - with self.assertRaises(googlemaps.exceptions.ApiError): - results = self.client.elevation_along_path( - [(40.714728, -73.998672)], 5) - - @responses.activate - def test_elevation_along_path(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/elevation/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - path = [(40.714728, -73.998672), (-34.397, 150.644)] - - results = self.client.elevation_along_path(path, 5) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/elevation/json?' - 'path=40.714728%%2C-73.998672%%7C-34.397000%%2C150.644000&' - 'key=%s&samples=5' % self.key, - responses.calls[0].request.url) - diff --git a/test/test_geocoding.py b/test/test_geocoding.py deleted file mode 100644 index 2aba3f9d..00000000 --- a/test/test_geocoding.py +++ /dev/null @@ -1,281 +0,0 @@ -# This Python file uses the following encoding: utf-8 -# -# Copyright 2014 Google Inc. All rights reserved. -# -# -# 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. -# - -"""Tests for the geocoding module.""" - -import datetime -import responses - -import test as _test -import googlemaps - -class GeocodingTest(_test.TestCase): - - def setUp(self): - self.key = 'AIzaasdf' - self.client = googlemaps.Client(self.key) - - @responses.activate - def test_simple_geocode(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('Sydney') - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&address=Sydney' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_reverse_geocode(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.reverse_geocode((-33.8674869, 151.2069902)) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'latlng=-33.867487%%2C151.206990&key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_geocoding_the_googleplex(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('1600 Amphitheatre Parkway, ' - 'Mountain View, CA') - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&address=1600+Amphitheatre+Parkway%%2C+Mountain' - '+View%%2C+CA' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_geocode_with_bounds(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('Winnetka', - bounds={'southwest': (34.172684, -118.604794), - 'northeast':(34.236144, -118.500938)}) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'bounds=34.172684%%2C-118.604794%%7C34.236144%%2C' - '-118.500938&key=%s&address=Winnetka' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_geocode_with_region_biasing(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('Toledo', region='es') - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'region=es&key=%s&address=Toledo' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_geocode_with_component_filter(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('santa cruz', - components={'country': 'ES'}) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&components=country%%3AES&address=santa+cruz' % - self.key, - responses.calls[0].request.url) - - @responses.activate - def test_geocode_with_multiple_component_filters(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('Torun', - components={'administrative_area': 'TX','country': 'US'}) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&components=administrative_area%%3ATX%%7C' - 'country%%3AUS&address=Torun' % self.key, - responses.calls[0].request.url) - - - @responses.activate - def test_geocode_with_just_components(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode( - components={'route': 'Annegatan', - 'administrative_area': 'Helsinki', - 'country': 'Finland'}) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&components=administrative_area%%3AHelsinki' - '%%7Ccountry%%3AFinland%%7Croute%%3AAnnegatan' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_simple_reverse_geocode(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.reverse_geocode((40.714224, -73.961452)) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'latlng=40.714224%%2C-73.961452&key=%s' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_reverse_geocode_restricted_by_type(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.reverse_geocode((40.714224, -73.961452), - location_type='ROOFTOP', - result_type='street_address') - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'latlng=40.714224%%2C-73.961452&result_type=street_address&' - 'key=%s&location_type=ROOFTOP' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_reverse_geocode_multiple_location_types(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.reverse_geocode((40.714224, -73.961452), - location_type=['ROOFTOP', - 'RANGE_INTERPOLATED'], - result_type='street_address') - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'latlng=40.714224%%2C-73.961452&result_type=street_address&' - 'key=%s&location_type=ROOFTOP%%7CRANGE_INTERPOLATED' % - self.key, - responses.calls[0].request.url) - - @responses.activate - def test_reverse_geocode_multiple_result_types(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.reverse_geocode((40.714224, -73.961452), - location_type='ROOFTOP', - result_type=['street_address', - 'route']) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'latlng=40.714224%%2C-73.961452&result_type=street_address' - '%%7Croute&key=%s&location_type=ROOFTOP' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_partial_match(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode('Pirrama Pyrmont') - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&address=Pirrama+Pyrmont' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_utf_results(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - results = self.client.geocode(components={'postal_code': '96766'}) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual('https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&components=postal_code%%3A96766' % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_utf8_request(self): - responses.add(responses.GET, - 'https://maps.googleapis.com/maps/api/geocode/json', - body='{"status":"OK","results":[]}', - status=200, - content_type='application/json') - - self.client.geocode(self.u('\\u4e2d\\u56fd')) # China - self.assertURLEqual( - 'https://maps.googleapis.com/maps/api/geocode/json?' - 'key=%s&address=%s' % (self.key, '%E4%B8%AD%E5%9B%BD'), - responses.calls[0].request.url) diff --git a/test/test_roads.py b/test/test_roads.py deleted file mode 100644 index 9a357289..00000000 --- a/test/test_roads.py +++ /dev/null @@ -1,120 +0,0 @@ -# -# Copyright 2015 Google Inc. All rights reserved. -# -# -# 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. -# - -"""Tests for the roads module.""" - - -import googlemaps - -import responses -import test as _test - -class RoadsTest(_test.TestCase): - - def setUp(self): - self.key = "AIzaasdf" - self.client = googlemaps.Client(self.key) - - @responses.activate - def test_snap(self): - responses.add(responses.GET, - "https://roads.googleapis.com/v1/snapToRoads", - body='{"snappedPoints":["foo"]}', - status=200, - content_type="application/json") - - results = self.client.snap_to_roads((40.714728, -73.998672)) - self.assertEqual("foo", results[0]) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual("https://roads.googleapis.com/v1/snapToRoads?" - "path=40.714728%%2C-73.998672&key=%s" % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_path(self): - responses.add(responses.GET, - "https://roads.googleapis.com/v1/speedLimits", - body='{"speedLimits":["foo"]}', - status=200, - content_type="application/json") - - results = self.client.snapped_speed_limits([(1, 2),(3, 4)]) - self.assertEqual("foo", results["speedLimits"][0]) - - self.assertEqual(1, len(responses.calls)) - self.assertURLEqual("https://roads.googleapis.com/v1/speedLimits?" - "path=1.000000%%2C2.000000|3.000000%%2C4.000000" - "&key=%s" % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_speedlimits(self): - responses.add(responses.GET, - "https://roads.googleapis.com/v1/speedLimits", - body='{"speedLimits":["foo"]}', - status=200, - content_type="application/json") - - results = self.client.speed_limits("id1") - self.assertEqual("foo", results[0]) - self.assertEqual("https://roads.googleapis.com/v1/speedLimits?" - "placeId=id1&key=%s" % self.key, - responses.calls[0].request.url) - - @responses.activate - def test_speedlimits_multiple(self): - responses.add(responses.GET, - "https://roads.googleapis.com/v1/speedLimits", - body='{"speedLimits":["foo"]}', - status=200, - content_type="application/json") - - results = self.client.speed_limits(["id1", "id2", "id3"]) - self.assertEqual("foo", results[0]) - self.assertEqual("https://roads.googleapis.com/v1/speedLimits?" - "placeId=id1&placeId=id2&placeId=id3" - "&key=%s" % self.key, - responses.calls[0].request.url) - - def test_clientid_not_accepted(self): - client = googlemaps.Client(client_id="asdf", client_secret="asdf") - - with self.assertRaises(ValueError): - client.speed_limits("foo") - - @responses.activate - def test_retry(self): - class request_callback: - def __init__(self): - self.first_req = True - - def __call__(self, req): - if self.first_req: - self.first_req = False - return (500, {}, 'Internal Server Error.') - return (200, {}, '{"speedLimits":[]}') - - responses.add_callback(responses.GET, - "https://roads.googleapis.com/v1/speedLimits", - content_type="application/json", - callback=request_callback()) - - self.client.speed_limits([]) - - self.assertEqual(2, len(responses.calls)) - self.assertEqual(responses.calls[0].request.url, responses.calls[1].request.url) diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index 5646081a..00000000 --- a/test_requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -nose -responses==0.3 diff --git a/test/__init__.py b/tests/__init__.py similarity index 90% rename from test/__init__.py rename to tests/__init__.py index 38c70917..8a32b1ed 100644 --- a/test/__init__.py +++ b/tests/__init__.py @@ -18,14 +18,10 @@ import unittest import codecs -try: # Python 3 - from urllib.parse import urlparse, parse_qsl -except ImportError: # Python 2 - from urlparse import urlparse, parse_qsl +from urllib.parse import urlparse, parse_qsl class TestCase(unittest.TestCase): - def assertURLEqual(self, first, second, msg=None): """Check that two arguments are equivalent URLs. Ignores the order of query arguments. diff --git a/tests/test_addressvalidation.py b/tests/test_addressvalidation.py new file mode 100644 index 00000000..d1f2f589 --- /dev/null +++ b/tests/test_addressvalidation.py @@ -0,0 +1,48 @@ +# This Python file uses the following encoding: utf-8 +# +# Copyright 2017 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the addressvalidation module.""" + +import responses + +import googlemaps +from . import TestCase + + +class AddressValidationTest(TestCase): + def setUp(self): + self.key = "AIzaSyD_sJl0qMA65CYHMBokVfMNA7AKyt5ERYs" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_simple_addressvalidation(self): + responses.add( + responses.POST, + "https://addressvalidation.googleapis.com/v1:validateAddress", + body='{"address": {"regionCode": "US","locality": "Mountain View","addressLines": "1600 Amphitheatre Pkwy"},"enableUspsCass":true}', + status=200, + content_type="application/json", + ) + + results = self.client.addressvalidation('1600 Amphitheatre Pk', regionCode='US', locality='Mountain View', enableUspsCass=True) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://addressvalidation.googleapis.com/v1:validateAddress?" "key=%s" % self.key, + responses.calls[0].request.url, + ) \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..4f01e397 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,432 @@ +# +# Copyright 2014 Google Inc. All rights reserved. +# +# +# 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. +# + + +"""Tests for client module.""" + +import time + +import responses +import requests +import uuid + +import googlemaps +import googlemaps.client as _client +from . import TestCase +from googlemaps.client import _X_GOOG_MAPS_EXPERIENCE_ID + + +class ClientTest(TestCase): + def test_no_api_key(self): + with self.assertRaises(Exception): + client = googlemaps.Client() + client.directions("Sydney", "Melbourne") + + def test_invalid_api_key(self): + with self.assertRaises(Exception): + client = googlemaps.Client(key="Invalid key.") + client.directions("Sydney", "Melbourne") + + def test_urlencode(self): + # See GH #72. + encoded_params = _client.urlencode_params([("address", "=Sydney ~")]) + self.assertEqual("address=%3DSydney+~", encoded_params) + + @responses.activate + def test_queries_per_second(self): + # This test assumes that the time to run a mocked query is + # relatively small, eg a few milliseconds. We define a rate of + # 3 queries per second, and run double that, which should take at + # least 1 second but no more than 2. + queries_per_second = 3 + query_range = range(queries_per_second * 2) + for _ in query_range: + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + client = googlemaps.Client( + key="AIzaasdf", queries_per_second=queries_per_second + ) + start = time.time() + for _ in query_range: + client.geocode("Sesame St.") + end = time.time() + self.assertTrue(start + 1 < end < start + 2) + + @responses.activate + def test_key_sent(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf") + client.geocode("Sesame St.") + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=AIzaasdf&address=Sesame+St.", + responses.calls[0].request.url, + ) + + @responses.activate + def test_extra_params(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf") + client.geocode("Sesame St.", extra_params={"foo": "bar"}) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=AIzaasdf&address=Sesame+St.&foo=bar", + responses.calls[0].request.url, + ) + + def test_hmac(self): + """ + From http://en.wikipedia.org/wiki/Hash-based_message_authentication_code + + HMAC_SHA1("key", "The quick brown fox jumps over the lazy dog") + = 0xde7c9b85b8b78aa6bc8a7a36f70a90701c9db4d9 + """ + + message = "The quick brown fox jumps over the lazy dog" + key = "a2V5" # "key" -> base64 + signature = "3nybhbi3iqa8ino29wqQcBydtNk=" + + self.assertEqual(signature, _client.sign_hmac(key, message)) + + @responses.activate + def test_url_signed(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(client_id="foo", client_secret="a2V5") + client.geocode("Sesame St.") + + self.assertEqual(1, len(responses.calls)) + + # Check ordering of parameters. + self.assertIn( + "address=Sesame+St.&client=foo&signature", responses.calls[0].request.url + ) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "address=Sesame+St.&client=foo&" + "signature=fxbWUIcNPZSekVOhp2ul9LW5TpY=", + responses.calls[0].request.url, + ) + + @responses.activate + def test_ua_sent(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf") + client.geocode("Sesame St.") + + self.assertEqual(1, len(responses.calls)) + user_agent = responses.calls[0].request.headers["User-Agent"] + self.assertTrue(user_agent.startswith("GoogleGeoApiClientPython")) + + @responses.activate + def test_retry(self): + class request_callback: + def __init__(self): + self.first_req = True + + def __call__(self, req): + if self.first_req: + self.first_req = False + return (200, {}, '{"status":"OVER_QUERY_LIMIT"}') + return (200, {}, '{"status":"OK","results":[]}') + + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + content_type="application/json", + callback=request_callback(), + ) + + client = googlemaps.Client(key="AIzaasdf") + client.geocode("Sesame St.") + + self.assertEqual(2, len(responses.calls)) + self.assertEqual(responses.calls[0].request.url, responses.calls[1].request.url) + + @responses.activate + def test_transport_error(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + status=404, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf") + with self.assertRaises(googlemaps.exceptions.HTTPError) as e: + client.geocode("Foo") + + self.assertEqual(e.exception.status_code, 404) + + @responses.activate + def test_host_override_on_init(self): + responses.add( + responses.GET, + "https://foo.com/bar", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf", base_url="https://foo.com") + client._get("/bar", {}) + + self.assertEqual(1, len(responses.calls)) + + @responses.activate + def test_host_override_per_request(self): + responses.add( + responses.GET, + "https://foo.com/bar", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf") + client._get("/bar", {}, base_url="https://foo.com") + + self.assertEqual(1, len(responses.calls)) + + @responses.activate + def test_custom_extract(self): + def custom_extract(resp): + return resp.json() + + responses.add( + responses.GET, + "https://maps.googleapis.com/bar", + body='{"error":"errormessage"}', + status=403, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf") + b = client._get("/bar", {}, extract_body=custom_extract) + self.assertEqual(1, len(responses.calls)) + self.assertEqual("errormessage", b["error"]) + + @responses.activate + def test_retry_intermittent(self): + class request_callback: + def __init__(self): + self.first_req = True + + def __call__(self, req): + if self.first_req: + self.first_req = False + return (500, {}, "Internal Server Error.") + return (200, {}, '{"status":"OK","results":[]}') + + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + content_type="application/json", + callback=request_callback(), + ) + + client = googlemaps.Client(key="AIzaasdf") + client.geocode("Sesame St.") + + self.assertEqual(2, len(responses.calls)) + + def test_invalid_channel(self): + # Cf. limitations here: + # https://developers.google.com/maps/premium/reports + # /usage-reports#channels + with self.assertRaises(ValueError): + client = googlemaps.Client( + client_id="foo", client_secret="a2V5", channel="auieauie$? " + ) + + def test_auth_url_with_channel(self): + client = googlemaps.Client( + key="AIzaasdf", client_id="foo", client_secret="a2V5", channel="MyChannel_1" + ) + + # Check ordering of parameters + signature. + auth_url = client._generate_auth_url( + "/test", {"param": "param"}, accepts_clientid=True + ) + self.assertEqual( + auth_url, + "/test?param=param" + "&channel=MyChannel_1" + "&client=foo" + "&signature=OH18GuQto_mEpxj99UimKskvo4k=", + ) + + # Check if added to requests to API with accepts_clientid=False + auth_url = client._generate_auth_url( + "/test", {"param": "param"}, accepts_clientid=False + ) + self.assertEqual(auth_url, "/test?param=param&key=AIzaasdf") + + def test_requests_version(self): + client_args_timeout = { + "key": "AIzaasdf", + "client_id": "foo", + "client_secret": "a2V5", + "channel": "MyChannel_1", + "connect_timeout": 5, + "read_timeout": 5, + } + client_args = client_args_timeout.copy() + del client_args["connect_timeout"] + del client_args["read_timeout"] + + requests.__version__ = "2.3.0" + with self.assertRaises(NotImplementedError): + googlemaps.Client(**client_args_timeout) + googlemaps.Client(**client_args) + + requests.__version__ = "2.4.0" + googlemaps.Client(**client_args_timeout) + googlemaps.Client(**client_args) + + def test_single_experience_id(self): + experience_id1 = "Exp1" + client = googlemaps.Client(key="AIzaasdf", experience_id=experience_id1) + self.assertEqual(experience_id1, client.get_experience_id()) + + experience_id2 = "Exp2" + client.set_experience_id(experience_id2) + self.assertEqual(experience_id2, client.get_experience_id()) + + def test_multiple_experience_id(self): + client = googlemaps.Client(key="AIzaasdf") + + experience_id1 = "Exp1" + experience_id2 = "Exp2" + client.set_experience_id(experience_id1, experience_id2) + + result = "%s,%s" % (experience_id1, experience_id2) + self.assertEqual(result, client.get_experience_id()) + + def test_no_experience_id(self): + client = googlemaps.Client(key="AIzaasdf") + self.assertIsNone(client.get_experience_id()) + + def test_clearing_experience_id(self): + client = googlemaps.Client(key="AIzaasdf") + client.set_experience_id("ExpId") + client.clear_experience_id() + self.assertIsNone(client.get_experience_id()) + + def test_experience_id_sample(self): + # [START maps_experience_id] + experience_id = str(uuid.uuid4()) + + # instantiate client with experience id + client = googlemaps.Client(key="AIza-Maps-API-Key", experience_id=experience_id) + + # clear the current experience id + client.clear_experience_id() + + # set a new experience id + other_experience_id = str(uuid.uuid4()) + client.set_experience_id(experience_id, other_experience_id) + + # make API request, the client will set the header + # X-GOOG-MAPS-EXPERIENCE-ID: experience_id,other_experience_id + + # get current experience id + ids = client.get_experience_id() + # [END maps_experience_id] + + result = "%s,%s" % (experience_id, other_experience_id) + self.assertEqual(result, ids) + + @responses.activate + def _perform_mock_request(self, experience_id=None): + # Mock response + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + # Perform network call + client = googlemaps.Client(key="AIzaasdf") + client.set_experience_id(experience_id) + client.geocode("Sesame St.") + return responses.calls[0].request + + def test_experience_id_in_header(self): + experience_id = "Exp1" + request = self._perform_mock_request(experience_id) + header_value = request.headers[_X_GOOG_MAPS_EXPERIENCE_ID] + self.assertEqual(experience_id, header_value) + + def test_experience_id_no_in_header(self): + request = self._perform_mock_request() + self.assertIsNone(request.headers.get(_X_GOOG_MAPS_EXPERIENCE_ID)) + + @responses.activate + def test_no_retry_over_query_limit(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/foo", + body='{"status":"OVER_QUERY_LIMIT"}', + status=200, + content_type="application/json", + ) + + client = googlemaps.Client(key="AIzaasdf", retry_over_query_limit=False) + + with self.assertRaises(googlemaps.exceptions.ApiError): + client._request("/foo", {}) + + self.assertEqual(1, len(responses.calls)) diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 00000000..39546aee --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,181 @@ +# +# Copyright 2014 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the convert module.""" + +import datetime +import unittest +import pytest + +from googlemaps import convert + + +class ConvertTest(unittest.TestCase): + def test_latlng(self): + expected = "1,2" + ll = {"lat": 1, "lng": 2} + self.assertEqual(expected, convert.latlng(ll)) + + ll = [1, 2] + self.assertEqual(expected, convert.latlng(ll)) + + ll = (1, 2) + self.assertEqual(expected, convert.latlng(ll)) + + self.assertEqual(expected, convert.latlng(expected)) + + with self.assertRaises(TypeError): + convert.latlng(1) + + def test_location_list(self): + expected = "1,2|1,2" + ll = [{"lat": 1, "lng": 2}, {"lat": 1, "lng": 2}] + self.assertEqual(expected, convert.location_list(ll)) + + ll = [[1, 2], [1, 2]] + self.assertEqual(expected, convert.location_list(ll)) + + ll = [(1, 2), [1, 2]] + self.assertEqual(expected, convert.location_list(ll)) + + self.assertEqual(expected, convert.location_list(expected)) + + with self.assertRaises(TypeError): + convert.latlng(1) + + def test_join_list(self): + self.assertEqual("asdf", convert.join_list("|", "asdf")) + + self.assertEqual("1,2,A", convert.join_list(",", ["1", "2", "A"])) + + self.assertEqual("", convert.join_list(",", [])) + + self.assertEqual("a,B", convert.join_list(",", ("a", "B"))) + + def test_as_list(self): + self.assertEqual([1], convert.as_list(1)) + + self.assertEqual([1, 2, 3], convert.as_list([1, 2, 3])) + + self.assertEqual(["string"], convert.as_list("string")) + + self.assertEqual((1, 2), convert.as_list((1, 2))) + + a_dict = {"a": 1} + self.assertEqual([a_dict], convert.as_list(a_dict)) + + def test_time(self): + self.assertEqual("1409810596", convert.time(1409810596)) + + dt = datetime.datetime.fromtimestamp(1409810596) + self.assertEqual("1409810596", convert.time(dt)) + + def test_components(self): + c = {"country": "US"} + self.assertEqual("country:US", convert.components(c)) + + c = {"country": "US", "foo": 1} + self.assertEqual("country:US|foo:1", convert.components(c)) + + c = {"country": ["US", "AU"], "foo": 1} + self.assertEqual("country:AU|country:US|foo:1", convert.components(c)) + + with self.assertRaises(TypeError): + convert.components("test") + + with self.assertRaises(TypeError): + convert.components(1) + + with self.assertRaises(TypeError): + convert.components(("c", "b")) + + def test_bounds(self): + ne = {"lat": 1, "lng": 2} + sw = (3, 4) + b = {"northeast": ne, "southwest": sw} + self.assertEqual("3,4|1,2", convert.bounds(b)) + + with self.assertRaises(TypeError): + convert.bounds("test") + + def test_size(self): + self.assertEqual("1x1", convert.size(1)) + + self.assertEqual("2x3", convert.size((2, 3))) + + with self.assertRaises(TypeError): + convert.size("test") + + def test_polyline_decode(self): + syd_mel_route = ( + "rvumEis{y[`NsfA~tAbF`bEj^h{@{KlfA~eA~`AbmEghAt~D|e@j" + "lRpO~yH_\\v}LjbBh~FdvCxu@`nCplDbcBf_B|wBhIfhCnqEb~D~" + "jCn_EngApdEtoBbfClf@t_CzcCpoEr_Gz_DxmAphDjjBxqCviEf}" + "B|pEvsEzbE~qGfpExjBlqCx}BvmLb`FbrQdpEvkAbjDllD|uDldD" + "j`Ef|AzcEx_Gtm@vuI~xArwD`dArlFnhEzmHjtC~eDluAfkC|eAd" + "hGpJh}N_mArrDlr@h|HzjDbsAvy@~~EdTxpJje@jlEltBboDjJdv" + "KyZpzExrAxpHfg@pmJg[tgJuqBnlIarAh}DbN`hCeOf_IbxA~uFt" + "|A|xEt_ArmBcN|sB|h@b_DjOzbJ{RlxCcfAp~AahAbqG~Gr}AerA" + "`dCwlCbaFo]twKt{@bsG|}A~fDlvBvz@tw@rpD_r@rqB{PvbHek@" + "vsHlh@ptNtm@fkD[~xFeEbyKnjDdyDbbBtuA|~Br|Gx_AfxCt}Cj" + "nHv`Ew\\lnBdrBfqBraD|{BldBxpG|]jqC`mArcBv]rdAxgBzdEb" + "{InaBzyC}AzaEaIvrCzcAzsCtfD~qGoPfeEh]h`BxiB`e@`kBxfA" + "v^pyA`}BhkCdoCtrC~bCxhCbgEplKrk@tiAteBwAxbCwuAnnCc]b" + "{FjrDdjGhhGzfCrlDruBzSrnGhvDhcFzw@n{@zxAf}Fd{IzaDnbD" + "joAjqJjfDlbIlzAraBxrB}K~`GpuD~`BjmDhkBp{@r_AxCrnAjrC" + "x`AzrBj{B|r@~qBbdAjtDnvCtNzpHxeApyC|GlfM`fHtMvqLjuEt" + "lDvoFbnCt|@xmAvqBkGreFm~@hlHw|AltC}NtkGvhBfaJ|~@riAx" + "uC~gErwCttCzjAdmGuF`iFv`AxsJftD|nDr_QtbMz_DheAf~Buy@" + "rlC`i@d_CljC`gBr|H|nAf_Fh{G|mE~kAhgKviEpaQnu@zwAlrA`" + "G~gFnvItz@j{Cng@j{D{]`tEftCdcIsPz{DddE~}PlnE|dJnzG`e" + "G`mF|aJdqDvoAwWjzHv`H`wOtjGzeXhhBlxErfCf{BtsCjpEjtD|" + "}Aja@xnAbdDt|ErMrdFh{CzgAnlCnr@`wEM~mE`bA`uD|MlwKxmB" + "vuFlhB|sN`_@fvBp`CxhCt_@loDsS|eDlmChgFlqCbjCxk@vbGxm" + "CjbMba@rpBaoClcCk_DhgEzYdzBl\\vsA_JfGztAbShkGtEhlDzh" + "C~w@hnB{e@yF}`D`_Ayx@~vGqn@l}CafC" + ) + + points = convert.decode_polyline(syd_mel_route) + self.assertAlmostEqual(-33.86746, points[0]["lat"]) + self.assertAlmostEqual(151.207090, points[0]["lng"]) + self.assertAlmostEqual(-37.814130, points[-1]["lat"]) + self.assertAlmostEqual(144.963180, points[-1]["lng"]) + + def test_polyline_round_trip(self): + test_polyline = ( + "gcneIpgxzRcDnBoBlEHzKjBbHlG`@`IkDxIi" + "KhKoMaLwTwHeIqHuAyGXeB~Ew@fFjAtIzExF" + ) + + points = convert.decode_polyline(test_polyline) + actual_polyline = convert.encode_polyline(points) + self.assertEqual(test_polyline, actual_polyline) + + +@pytest.mark.parametrize( + "value, expected", + [ + (40, "40"), + (40.0, "40"), + (40.1, "40.1"), + (40.00000001, "40.00000001"), + (40.000000009, "40.00000001"), + (40.000000001, "40"), + ], +) +def test_format_float(value, expected): + assert convert.format_float(value) == expected diff --git a/tests/test_directions.py b/tests/test_directions.py new file mode 100644 index 00000000..5a3c477a --- /dev/null +++ b/tests/test_directions.py @@ -0,0 +1,325 @@ +# +# Copyright 2014 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the directions module.""" + +from datetime import datetime +from datetime import timedelta +import time + +import responses + +import googlemaps +from . import TestCase + + +class DirectionsTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_simple_directions(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + # Simplest directions request. Driving directions by default. + routes = self.client.directions("Sydney", "Melbourne") + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json" + "?origin=Sydney&destination=Melbourne&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_complex_request(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions( + "Sydney", + "Melbourne", + mode="bicycling", + avoid=["highways", "tolls", "ferries"], + units="metric", + region="us", + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Sydney&avoid=highways%%7Ctolls%%7Cferries&" + "destination=Melbourne&mode=bicycling&key=%s" + "&units=metric®ion=us" % self.key, + responses.calls[0].request.url, + ) + + def test_transit_without_time(self): + # With mode of transit, we need a departure_time or an + # arrival_time specified + with self.assertRaises(googlemaps.exceptions.ApiError): + self.client.directions( + "Sydney Town Hall", "Parramatta, NSW", mode="transit" + ) + + @responses.activate + def test_transit_with_departure_time(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + now = datetime.now() + routes = self.client.directions( + "Sydney Town Hall", + "Parramatta, NSW", + mode="transit", + traffic_model="optimistic", + departure_time=now, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?origin=" + "Sydney+Town+Hall&key=%s&destination=Parramatta%%2C+NSW&" + "mode=transit&departure_time=%d&traffic_model=optimistic" + % (self.key, time.mktime(now.timetuple())), + responses.calls[0].request.url, + ) + + @responses.activate + def test_transit_with_arrival_time(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + an_hour_from_now = datetime.now() + timedelta(hours=1) + routes = self.client.directions( + "Sydney Town Hall", + "Parramatta, NSW", + mode="transit", + arrival_time=an_hour_from_now, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Sydney+Town+Hall&arrival_time=%d&" + "destination=Parramatta%%2C+NSW&mode=transit&key=%s" + % (time.mktime(an_hour_from_now.timetuple()), self.key), + responses.calls[0].request.url, + ) + + def test_invalid_travel_mode(self): + with self.assertRaises(ValueError): + self.client.directions( + "48 Pirrama Road, Pyrmont, NSW", "Sydney Town Hall", mode="crawling" + ) + + @responses.activate + def test_travel_mode_round_trip(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions( + "Town Hall, Sydney", "Parramatta, NSW", mode="bicycling" + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Town+Hall%%2C+Sydney&destination=Parramatta%%2C+NSW&" + "mode=bicycling&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_brooklyn_to_queens_by_transit(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + now = datetime.now() + routes = self.client.directions( + "Brooklyn", "Queens", mode="transit", departure_time=now + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Brooklyn&key=%s&destination=Queens&mode=transit&" + "departure_time=%d" % (self.key, time.mktime(now.timetuple())), + responses.calls[0].request.url, + ) + + @responses.activate + def test_boston_to_concord_via_charlestown_and_lexington(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions( + "Boston, MA", "Concord, MA", waypoints=["Charlestown, MA", "Lexington, MA"] + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Boston%%2C+MA&destination=Concord%%2C+MA&" + "waypoints=Charlestown%%2C+MA%%7CLexington%%2C+MA&" + "key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_adelaide_wine_tour(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions( + "Adelaide, SA", + "Adelaide, SA", + waypoints=[ + "Barossa Valley, SA", + "Clare, SA", + "Connawarra, SA", + "McLaren Vale, SA", + ], + optimize_waypoints=True, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Adelaide%%2C+SA&destination=Adelaide%%2C+SA&" + "waypoints=optimize%%3Atrue%%7CBarossa+Valley%%2C+" + "SA%%7CClare%%2C+SA%%7CConnawarra%%2C+SA%%7CMcLaren+" + "Vale%%2C+SA&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_toledo_to_madrid_in_spain(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions("Toledo", "Madrid", region="es") + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Toledo®ion=es&destination=Madrid&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_zero_results_returns_response(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"ZERO_RESULTS","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions("Toledo", "Madrid") + self.assertIsNotNone(routes) + self.assertEqual(0, len(routes)) + + @responses.activate + def test_language_parameter(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions("Toledo", "Madrid", region="es", language="es") + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Toledo®ion=es&destination=Madrid&key=%s&" + "language=es" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_alternatives(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + body='{"status":"OK","routes":[]}', + status=200, + content_type="application/json", + ) + + routes = self.client.directions( + "Sydney Town Hall", "Parramatta Town Hall", alternatives=True + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/directions/json?" + "origin=Sydney+Town+Hall&destination=Parramatta+Town+Hall&" + "alternatives=true&key=%s" % self.key, + responses.calls[0].request.url, + ) diff --git a/tests/test_distance_matrix.py b/tests/test_distance_matrix.py new file mode 100644 index 00000000..6946782e --- /dev/null +++ b/tests/test_distance_matrix.py @@ -0,0 +1,218 @@ +# +# Copyright 2014 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the distance matrix module.""" + +from datetime import datetime +import time + +import responses + +import googlemaps +from . import TestCase + + +class DistanceMatrixTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_basic_params(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/distancematrix/json", + body='{"status":"OK","rows":[]}', + status=200, + content_type="application/json", + ) + + origins = [ + "Perth, Australia", + "Sydney, Australia", + "Melbourne, Australia", + "Adelaide, Australia", + "Brisbane, Australia", + "Darwin, Australia", + "Hobart, Australia", + "Canberra, Australia", + ] + destinations = [ + "Uluru, Australia", + "Kakadu, Australia", + "Blue Mountains, Australia", + "Bungle Bungles, Australia", + "The Pinnacles, Australia", + ] + + matrix = self.client.distance_matrix(origins, destinations) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/distancematrix/json?" + "key=%s&origins=Perth%%2C+Australia%%7CSydney%%2C+" + "Australia%%7CMelbourne%%2C+Australia%%7CAdelaide%%2C+" + "Australia%%7CBrisbane%%2C+Australia%%7CDarwin%%2C+" + "Australia%%7CHobart%%2C+Australia%%7CCanberra%%2C+Australia&" + "destinations=Uluru%%2C+Australia%%7CKakadu%%2C+Australia%%7C" + "Blue+Mountains%%2C+Australia%%7CBungle+Bungles%%2C+Australia" + "%%7CThe+Pinnacles%%2C+Australia" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_mixed_params(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/distancematrix/json", + body='{"status":"OK","rows":[]}', + status=200, + content_type="application/json", + ) + + origins = [ + "Bobcaygeon ON", [41.43206, -81.38992], + "place_id:ChIJ7cv00DwsDogRAMDACa2m4K8" + ] + destinations = [ + (43.012486, -83.6964149), + {"lat": 42.8863855, "lng": -78.8781627}, + ] + + matrix = self.client.distance_matrix(origins, destinations) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/distancematrix/json?" + "key=%s&origins=Bobcaygeon+ON%%7C41.43206%%2C-81.38992%%7C" + "place_id%%3AChIJ7cv00DwsDogRAMDACa2m4K8&" + "destinations=43.012486%%2C-83.6964149%%7C42.8863855%%2C" + "-78.8781627" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_all_params(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/distancematrix/json", + body='{"status":"OK","rows":[]}', + status=200, + content_type="application/json", + ) + + origins = [ + "Perth, Australia", + "Sydney, Australia", + "Melbourne, Australia", + "Adelaide, Australia", + "Brisbane, Australia", + "Darwin, Australia", + "Hobart, Australia", + "Canberra, Australia", + ] + destinations = [ + "Uluru, Australia", + "Kakadu, Australia", + "Blue Mountains, Australia", + "Bungle Bungles, Australia", + "The Pinnacles, Australia", + ] + + now = datetime.now() + matrix = self.client.distance_matrix( + origins, + destinations, + mode="driving", + language="en-AU", + avoid="tolls", + units="imperial", + departure_time=now, + traffic_model="optimistic", + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/distancematrix/json?" + "origins=Perth%%2C+Australia%%7CSydney%%2C+Australia%%7C" + "Melbourne%%2C+Australia%%7CAdelaide%%2C+Australia%%7C" + "Brisbane%%2C+Australia%%7CDarwin%%2C+Australia%%7CHobart%%2C+" + "Australia%%7CCanberra%%2C+Australia&language=en-AU&" + "avoid=tolls&mode=driving&key=%s&units=imperial&" + "destinations=Uluru%%2C+Australia%%7CKakadu%%2C+Australia%%7C" + "Blue+Mountains%%2C+Australia%%7CBungle+Bungles%%2C+Australia" + "%%7CThe+Pinnacles%%2C+Australia&departure_time=%d" + "&traffic_model=optimistic" % (self.key, time.mktime(now.timetuple())), + responses.calls[0].request.url, + ) + + @responses.activate + def test_lang_param(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/distancematrix/json", + body='{"status":"OK","rows":[]}', + status=200, + content_type="application/json", + ) + + origins = ["Vancouver BC", "Seattle"] + destinations = ["San Francisco", "Victoria BC"] + + matrix = self.client.distance_matrix( + origins, destinations, language="fr-FR", mode="bicycling" + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/distancematrix/json?" + "key=%s&language=fr-FR&mode=bicycling&" + "origins=Vancouver+BC%%7CSeattle&" + "destinations=San+Francisco%%7CVictoria+BC" % self.key, + responses.calls[0].request.url, + ) + @responses.activate + def test_place_id_param(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/distancematrix/json", + body='{"status":"OK","rows":[]}', + status=200, + content_type="application/json", + ) + + origins = [ + 'place_id:ChIJ7cv00DwsDogRAMDACa2m4K8', + 'place_id:ChIJzxcfI6qAa4cR1jaKJ_j0jhE', + ] + destinations = [ + 'place_id:ChIJPZDrEzLsZIgRoNrpodC5P30', + 'place_id:ChIJjQmTaV0E9YgRC2MLmS_e_mY', + ] + + matrix = self.client.distance_matrix(origins, destinations) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/distancematrix/json?" + "key=%s&" + "origins=place_id%%3AChIJ7cv00DwsDogRAMDACa2m4K8%%7C" + "place_id%%3AChIJzxcfI6qAa4cR1jaKJ_j0jhE&" + "destinations=place_id%%3AChIJPZDrEzLsZIgRoNrpodC5P30%%7C" + "place_id%%3AChIJjQmTaV0E9YgRC2MLmS_e_mY" % self.key, + responses.calls[0].request.url, + ) diff --git a/tests/test_elevation.py b/tests/test_elevation.py new file mode 100644 index 00000000..165f95b3 --- /dev/null +++ b/tests/test_elevation.py @@ -0,0 +1,134 @@ +# +# Copyright 2014 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the elevation module.""" + +import datetime + +import responses + +import googlemaps +from . import TestCase + + +class ElevationTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_elevation_single(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/elevation/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.elevation((40.714728, -73.998672)) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/elevation/json?" + "locations=enc:abowFtzsbM&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_elevation_single_list(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/elevation/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.elevation([(40.714728, -73.998672)]) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/elevation/json?" + "locations=enc:abowFtzsbM&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_elevation_multiple(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/elevation/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + locations = [(40.714728, -73.998672), (-34.397, 150.644)] + results = self.client.elevation(locations) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/elevation/json?" + "locations=enc:abowFtzsbMhgmiMuobzi@&key=%s" % self.key, + responses.calls[0].request.url, + ) + + def test_elevation_along_path_single(self): + with self.assertRaises(googlemaps.exceptions.ApiError): + results = self.client.elevation_along_path([(40.714728, -73.998672)], 5) + + @responses.activate + def test_elevation_along_path(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/elevation/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + path = [(40.714728, -73.998672), (-34.397, 150.644)] + + results = self.client.elevation_along_path(path, 5) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/elevation/json?" + "path=enc:abowFtzsbMhgmiMuobzi@&" + "key=%s&samples=5" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_short_latlng(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/elevation/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.elevation((40, -73)) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/elevation/json?" + "locations=40,-73&key=%s" % self.key, + responses.calls[0].request.url, + ) diff --git a/tests/test_geocoding.py b/tests/test_geocoding.py new file mode 100644 index 00000000..8734c8b8 --- /dev/null +++ b/tests/test_geocoding.py @@ -0,0 +1,389 @@ +# This Python file uses the following encoding: utf-8 +# +# Copyright 2014 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the geocoding module.""" + +import datetime + +import responses + +import googlemaps +from . import TestCase + + +class GeocodingTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_simple_geocode(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode("Sydney").get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&address=Sydney" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_reverse_geocode(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.reverse_geocode((-33.8674869, 151.2069902)).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "latlng=-33.8674869,151.2069902&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocoding_the_googleplex(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode("1600 Amphitheatre Parkway, " "Mountain View, CA").get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&address=1600+Amphitheatre+Parkway%%2C+Mountain" + "+View%%2C+CA" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocode_with_bounds(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode( + "Winnetka", + bounds={ + "southwest": (34.172684, -118.604794), + "northeast": (34.236144, -118.500938), + }, + ).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "bounds=34.172684%%2C-118.604794%%7C34.236144%%2C" + "-118.500938&key=%s&address=Winnetka" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocode_with_region_biasing(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode("Toledo", region="es").get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "region=es&key=%s&address=Toledo" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocode_with_component_filter(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode("santa cruz", components={"country": "ES"}).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&components=country%%3AES&address=santa+cruz" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocode_with_multiple_component_filters(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode( + "Torun", components={"administrative_area": "TX", "country": "US"} + ).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&components=administrative_area%%3ATX%%7C" + "country%%3AUS&address=Torun" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocode_with_just_components(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode( + components={ + "route": "Annegatan", + "administrative_area": "Helsinki", + "country": "Finland", + } + ).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&components=administrative_area%%3AHelsinki" + "%%7Ccountry%%3AFinland%%7Croute%%3AAnnegatan" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_geocode_place_id(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode(place_id="ChIJeRpOeF67j4AR9ydy_PIzPuM").get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&place_id=ChIJeRpOeF67j4AR9ydy_PIzPuM" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_simple_reverse_geocode(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.reverse_geocode((40.714224, -73.961452)).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "latlng=40.714224%%2C-73.961452&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_reverse_geocode_restricted_by_type(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.reverse_geocode( + (40.714224, -73.961452), + location_type="ROOFTOP", + result_type="street_address", + ).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "latlng=40.714224%%2C-73.961452&result_type=street_address&" + "key=%s&location_type=ROOFTOP" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_reverse_geocode_multiple_location_types(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.reverse_geocode( + (40.714224, -73.961452), + location_type=["ROOFTOP", "RANGE_INTERPOLATED"], + result_type="street_address", + ).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "latlng=40.714224%%2C-73.961452&result_type=street_address&" + "key=%s&location_type=ROOFTOP%%7CRANGE_INTERPOLATED" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_reverse_geocode_multiple_result_types(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.reverse_geocode( + (40.714224, -73.961452), + location_type="ROOFTOP", + result_type=["street_address", "route"], + ).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "latlng=40.714224%%2C-73.961452&result_type=street_address" + "%%7Croute&key=%s&location_type=ROOFTOP" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_reverse_geocode_with_address_descriptors(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[], "address_descriptor":{ "landmarks": [ { "placeId": "id" } ] } }', + status=200, + content_type="application/json", + ) + + response = self.client.reverse_geocode((-33.8674869, 151.2069902), enable_address_descriptor=True) + + address_descriptor = response.get("address_descriptor", []) + + self.assertEqual(1, len(address_descriptor["landmarks"])) + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "latlng=-33.8674869,151.2069902&enable_address_descriptor=true&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_partial_match(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode("Pirrama Pyrmont").get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&address=Pirrama+Pyrmont" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_utf_results(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + results = self.client.geocode(components={"postal_code": "96766"}).get("results", []) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&components=postal_code%%3A96766" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_utf8_request(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + + self.client.geocode(self.u("\\u4e2d\\u56fd")).get("results", []) # China + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/geocode/json?" + "key=%s&address=%s" % (self.key, "%E4%B8%AD%E5%9B%BD"), + responses.calls[0].request.url, + ) diff --git a/tests/test_geolocation.py b/tests/test_geolocation.py new file mode 100644 index 00000000..8eeb2cbc --- /dev/null +++ b/tests/test_geolocation.py @@ -0,0 +1,48 @@ +# This Python file uses the following encoding: utf-8 +# +# Copyright 2017 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the geocolocation module.""" + +import responses + +import googlemaps +from . import TestCase + + +class GeolocationTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_simple_geolocate(self): + responses.add( + responses.POST, + "https://www.googleapis.com/geolocation/v1/geolocate", + body='{"location": {"lat": 51.0,"lng": -0.1},"accuracy": 1200.4}', + status=200, + content_type="application/json", + ) + + results = self.client.geolocate() + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://www.googleapis.com/geolocation/v1/geolocate?" "key=%s" % self.key, + responses.calls[0].request.url, + ) diff --git a/tests/test_maps.py b/tests/test_maps.py new file mode 100644 index 00000000..8db6298f --- /dev/null +++ b/tests/test_maps.py @@ -0,0 +1,129 @@ +# +# Copyright 2020 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the maps module.""" + +from types import GeneratorType + +import responses + +import googlemaps +from . import TestCase + +from googlemaps.maps import StaticMapMarker +from googlemaps.maps import StaticMapPath + + +class MapsTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_static_map_marker(self): + marker = StaticMapMarker( + locations=[{"lat": -33.867486, "lng": 151.206990}, "Sydney"], + size="small", + color="blue", + label="S", + ) + + self.assertEqual( + "size:small|color:blue|label:S|" "-33.867486,151.20699|Sydney", str(marker) + ) + + with self.assertRaises(ValueError): + StaticMapMarker(locations=["Sydney"], label="XS") + + self.assertEqual("label:1|Sydney", str(StaticMapMarker(locations=["Sydney"], label="1"))) + + @responses.activate + def test_static_map_path(self): + path = StaticMapPath( + points=[{"lat": -33.867486, "lng": 151.206990}, "Sydney"], + weight=5, + color="red", + geodesic=True, + fillcolor="Red", + ) + + self.assertEqual( + "weight:5|color:red|fillcolor:Red|" + "geodesic:True|" + "-33.867486,151.20699|Sydney", + str(path), + ) + + @responses.activate + def test_download(self): + url = "https://maps.googleapis.com/maps/api/staticmap" + responses.add(responses.GET, url, status=200) + + path = StaticMapPath( + points=[(62.107733, -145.541936), "Delta+Junction,AK"], + weight=5, + color="red", + ) + + m1 = StaticMapMarker( + locations=[(62.107733, -145.541936)], color="blue", label="S" + ) + + m2 = StaticMapMarker( + locations=["Delta+Junction,AK"], size="tiny", color="green" + ) + + m3 = StaticMapMarker( + locations=["Tok,AK"], size="mid", color="0xFFFF00", label="C" + ) + + response = self.client.static_map( + size=(400, 400), + zoom=6, + center=(63.259591, -144.667969), + maptype="hybrid", + format="png", + scale=2, + visible=["Tok,AK"], + path=path, + markers=[m1, m2, m3], + ) + + self.assertTrue(isinstance(response, GeneratorType)) + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?center=63.259591%%2C-144.667969&format=png&maptype=hybrid&" + "markers=color%%3Ablue%%7Clabel%%3AS%%7C62.107733%%2C-145.541936&" + "markers=size%%3Atiny%%7Ccolor%%3Agreen%%7CDelta%%2BJunction%%2CAK&" + "markers=size%%3Amid%%7Ccolor%%3A0xFFFF00%%7Clabel%%3AC%%7CTok%%2CAK&" + "path=weight%%3A5%%7Ccolor%%3Ared%%7C62.107733%%2C-145.541936%%7CDelta%%2BJunction%%2CAK&" + "scale=2&size=400x400&visible=Tok%%2CAK&zoom=6&key=%s" % (url, self.key), + responses.calls[0].request.url, + ) + + with self.assertRaises(ValueError): + self.client.static_map(size=(400, 400)) + + with self.assertRaises(ValueError): + self.client.static_map( + size=(400, 400), center=(63.259591, -144.667969), zoom=6, format="test" + ) + + with self.assertRaises(ValueError): + self.client.static_map( + size=(400, 400), center=(63.259591, -144.667969), zoom=6, maptype="test" + ) diff --git a/tests/test_places.py b/tests/test_places.py new file mode 100644 index 00000000..32f03d16 --- /dev/null +++ b/tests/test_places.py @@ -0,0 +1,255 @@ +# This Python file uses the following encoding: utf-8 +# +# Copyright 2016 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the places module.""" + +import uuid + +from types import GeneratorType + +import responses + +import googlemaps +from . import TestCase + + +class PlacesTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + self.location = (-33.86746, 151.207090) + self.type = "liquor_store" + self.language = "en-AU" + self.region = "AU" + self.radius = 100 + + @responses.activate + def test_places_find(self): + url = "https://maps.googleapis.com/maps/api/place/findplacefromtext/json" + responses.add( + responses.GET, + url, + body='{"status": "OK", "candidates": []}', + status=200, + content_type="application/json", + ) + + self.client.find_place( + "restaurant", + "textquery", + fields=["business_status", "geometry/location", "place_id"], + location_bias="point:90,90", + language=self.language, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?language=en-AU&inputtype=textquery&" + "locationbias=point:90,90&input=restaurant" + "&fields=business_status,geometry/location,place_id&key=%s" + % (url, self.key), + responses.calls[0].request.url, + ) + + with self.assertRaises(ValueError): + self.client.find_place("restaurant", "invalid") + with self.assertRaises(ValueError): + self.client.find_place( + "restaurant", "textquery", fields=["geometry", "invalid"] + ) + with self.assertRaises(ValueError): + self.client.find_place("restaurant", "textquery", location_bias="invalid") + + @responses.activate + def test_places_text_search(self): + url = "https://maps.googleapis.com/maps/api/place/textsearch/json" + responses.add( + responses.GET, + url, + body='{"status": "OK", "results": [], "html_attributions": []}', + status=200, + content_type="application/json", + ) + + self.client.places( + "restaurant", + location=self.location, + radius=self.radius, + region=self.region, + language=self.language, + min_price=1, + max_price=4, + open_now=True, + type=self.type, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?language=en-AU&location=-33.86746%%2C151.20709&" + "maxprice=4&minprice=1&opennow=true&query=restaurant&" + "radius=100®ion=AU&type=liquor_store&key=%s" % (url, self.key), + responses.calls[0].request.url, + ) + + @responses.activate + def test_places_nearby_search(self): + url = "https://maps.googleapis.com/maps/api/place/nearbysearch/json" + responses.add( + responses.GET, + url, + body='{"status": "OK", "results": [], "html_attributions": []}', + status=200, + content_type="application/json", + ) + + self.client.places_nearby( + location=self.location, + keyword="foo", + language=self.language, + min_price=1, + max_price=4, + name="bar", + open_now=True, + rank_by="distance", + type=self.type, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?keyword=foo&language=en-AU&location=-33.86746%%2C151.20709&" + "maxprice=4&minprice=1&name=bar&opennow=true&rankby=distance&" + "type=liquor_store&key=%s" % (url, self.key), + responses.calls[0].request.url, + ) + + with self.assertRaises(ValueError): + self.client.places_nearby(radius=self.radius) + with self.assertRaises(ValueError): + self.client.places_nearby(self.location, rank_by="distance") + + with self.assertRaises(ValueError): + self.client.places_nearby( + location=self.location, + rank_by="distance", + keyword="foo", + radius=self.radius, + ) + + @responses.activate + def test_place_detail(self): + url = "https://maps.googleapis.com/maps/api/place/details/json" + responses.add( + responses.GET, + url, + body='{"status": "OK", "result": {}, "html_attributions": []}', + status=200, + content_type="application/json", + ) + + self.client.place( + "ChIJN1t_tDeuEmsRUsoyG83frY4", + fields=["business_status", "geometry/location", + "place_id", "reviews"], + language=self.language, + reviews_no_translations=True, + reviews_sort="newest", + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?language=en-AU&placeid=ChIJN1t_tDeuEmsRUsoyG83frY4" + "&reviews_no_translations=true&reviews_sort=newest" + "&key=%s&fields=business_status,geometry/location,place_id,reviews" + % (url, self.key), + responses.calls[0].request.url, + ) + + with self.assertRaises(ValueError): + self.client.place( + "ChIJN1t_tDeuEmsRUsoyG83frY4", fields=["geometry", "invalid"] + ) + + @responses.activate + def test_photo(self): + url = "https://maps.googleapis.com/maps/api/place/photo" + responses.add(responses.GET, url, status=200) + + ref = "CnRvAAAAwMpdHeWlXl-lH0vp7lez4znKPIWSWvgvZFISdKx45AwJVP1Qp37YOrH7sqHMJ8C-vBDC546decipPHchJhHZL94RcTUfPa1jWzo-rSHaTlbNtjh-N68RkcToUCuY9v2HNpo5mziqkir37WU8FJEqVBIQ4k938TI3e7bf8xq-uwDZcxoUbO_ZJzPxremiQurAYzCTwRhE_V0" + response = self.client.places_photo(ref, max_width=100) + + self.assertTrue(isinstance(response, GeneratorType)) + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?maxwidth=100&photoreference=%s&key=%s" % (url, ref, self.key), + responses.calls[0].request.url, + ) + + @responses.activate + def test_autocomplete(self): + url = "https://maps.googleapis.com/maps/api/place/autocomplete/json" + responses.add( + responses.GET, + url, + body='{"status": "OK", "predictions": []}', + status=200, + content_type="application/json", + ) + + session_token = uuid.uuid4().hex + + self.client.places_autocomplete( + "Google", + session_token=session_token, + offset=3, + origin=self.location, + location=self.location, + radius=self.radius, + language=self.language, + types="geocode", + components={"country": "au"}, + strict_bounds=True, + ) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?components=country%%3Aau&input=Google&language=en-AU&" + "origin=-33.86746%%2C151.20709&" + "location=-33.86746%%2C151.20709&offset=3&radius=100&" + "strictbounds=true&types=geocode&key=%s&sessiontoken=%s" + % (url, self.key, session_token), + responses.calls[0].request.url, + ) + + @responses.activate + def test_autocomplete_query(self): + url = "https://maps.googleapis.com/maps/api/place/queryautocomplete/json" + responses.add( + responses.GET, + url, + body='{"status": "OK", "predictions": []}', + status=200, + content_type="application/json", + ) + + self.client.places_autocomplete_query("pizza near New York") + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "%s?input=pizza+near+New+York&key=%s" % (url, self.key), + responses.calls[0].request.url, + ) diff --git a/tests/test_roads.py b/tests/test_roads.py new file mode 100644 index 00000000..bfea2bf7 --- /dev/null +++ b/tests/test_roads.py @@ -0,0 +1,158 @@ +# +# Copyright 2015 Google Inc. All rights reserved. +# +# +# 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. +# + +"""Tests for the roads module.""" + + +import responses + +import googlemaps +from . import TestCase + + +class RoadsTest(TestCase): + def setUp(self): + self.key = "AIzaasdf" + self.client = googlemaps.Client(self.key) + + @responses.activate + def test_snap(self): + responses.add( + responses.GET, + "https://roads.googleapis.com/v1/snapToRoads", + body='{"snappedPoints":["foo"]}', + status=200, + content_type="application/json", + ) + + results = self.client.snap_to_roads((40.714728, -73.998672)) + self.assertEqual("foo", results[0]) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://roads.googleapis.com/v1/snapToRoads?" + "path=40.714728%%2C-73.998672&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_nearest_roads(self): + responses.add( + responses.GET, + "https://roads.googleapis.com/v1/nearestRoads", + body='{"snappedPoints":["foo"]}', + status=200, + content_type="application/json", + ) + + results = self.client.nearest_roads((40.714728, -73.998672)) + self.assertEqual("foo", results[0]) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://roads.googleapis.com/v1/nearestRoads?" + "points=40.714728%%2C-73.998672&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_path(self): + responses.add( + responses.GET, + "https://roads.googleapis.com/v1/speedLimits", + body='{"speedLimits":["foo"]}', + status=200, + content_type="application/json", + ) + + results = self.client.snapped_speed_limits([(1, 2), (3, 4)]) + self.assertEqual("foo", results["speedLimits"][0]) + + self.assertEqual(1, len(responses.calls)) + self.assertURLEqual( + "https://roads.googleapis.com/v1/speedLimits?" + "path=1%%2C2|3%%2C4" + "&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_speedlimits(self): + responses.add( + responses.GET, + "https://roads.googleapis.com/v1/speedLimits", + body='{"speedLimits":["foo"]}', + status=200, + content_type="application/json", + ) + + results = self.client.speed_limits("id1") + self.assertEqual("foo", results[0]) + self.assertEqual( + "https://roads.googleapis.com/v1/speedLimits?" + "placeId=id1&key=%s" % self.key, + responses.calls[0].request.url, + ) + + @responses.activate + def test_speedlimits_multiple(self): + responses.add( + responses.GET, + "https://roads.googleapis.com/v1/speedLimits", + body='{"speedLimits":["foo"]}', + status=200, + content_type="application/json", + ) + + results = self.client.speed_limits(["id1", "id2", "id3"]) + self.assertEqual("foo", results[0]) + self.assertEqual( + "https://roads.googleapis.com/v1/speedLimits?" + "placeId=id1&placeId=id2&placeId=id3" + "&key=%s" % self.key, + responses.calls[0].request.url, + ) + + def test_clientid_not_accepted(self): + client = googlemaps.Client(client_id="asdf", client_secret="asdf") + + with self.assertRaises(ValueError): + client.speed_limits("foo") + + @responses.activate + def test_retry(self): + class request_callback: + def __init__(self): + self.first_req = True + + def __call__(self, req): + if self.first_req: + self.first_req = False + return (500, {}, "Internal Server Error.") + return (200, {}, '{"speedLimits":[]}') + + responses.add_callback( + responses.GET, + "https://roads.googleapis.com/v1/speedLimits", + content_type="application/json", + callback=request_callback(), + ) + + self.client.speed_limits([]) + + self.assertEqual(2, len(responses.calls)) + self.assertEqual(responses.calls[0].request.url, responses.calls[1].request.url) diff --git a/test/test_timezone.py b/tests/test_timezone.py similarity index 55% rename from test/test_timezone.py rename to tests/test_timezone.py index d5db4952..a1d7394e 100644 --- a/test/test_timezone.py +++ b/tests/test_timezone.py @@ -18,59 +18,65 @@ """Tests for the timezone module.""" -import responses -import mock import datetime +import responses +from unittest import mock import googlemaps -import test as _test - +from . import TestCase -class TimezoneTest(_test.TestCase): +class TimezoneTest(TestCase): def setUp(self): self.key = "AIzaasdf" self.client = googlemaps.Client(self.key) @responses.activate def test_los_angeles(self): - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/timezone/json", - body='{"status":"OK"}', - status=200, - content_type="application/json") + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/timezone/json", + body='{"status":"OK"}', + status=200, + content_type="application/json", + ) ts = 1331766000 timezone = self.client.timezone((39.603481, -119.682251), ts) self.assertIsNotNone(timezone) self.assertEqual(1, len(responses.calls)) - self.assertURLEqual("https://maps.googleapis.com/maps/api/timezone/json" - "?location=39.603481,-119.682251×tamp=%d" - "&key=%s" % - (ts, self.key), - responses.calls[0].request.url) - - class MockDatetime(object): + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/timezone/json" + "?location=39.603481,-119.682251×tamp=%d" + "&key=%s" % (ts, self.key), + responses.calls[0].request.url, + ) + class MockDatetime: def now(self): return datetime.datetime.fromtimestamp(1608) + utcnow = now + @responses.activate @mock.patch("googlemaps.timezone.datetime", MockDatetime()) def test_los_angeles_with_no_timestamp(self): - responses.add(responses.GET, - "https://maps.googleapis.com/maps/api/timezone/json", - body='{"status":"OK"}', - status=200, - content_type="application/json") + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/timezone/json", + body='{"status":"OK"}', + status=200, + content_type="application/json", + ) timezone = self.client.timezone((39.603481, -119.682251)) self.assertIsNotNone(timezone) self.assertEqual(1, len(responses.calls)) - self.assertURLEqual("https://maps.googleapis.com/maps/api/timezone/json" - "?location=39.603481,-119.682251×tamp=%d" - "&key=%s" % - (1608, self.key), - responses.calls[0].request.url) + self.assertURLEqual( + "https://maps.googleapis.com/maps/api/timezone/json" + "?location=39.603481,-119.682251×tamp=%d" + "&key=%s" % (1608, self.key), + responses.calls[0].request.url, + ) diff --git a/text.py b/text.py new file mode 100644 index 00000000..13734488 --- /dev/null +++ b/text.py @@ -0,0 +1,19 @@ +import math + +queries_quota : int +queries_per_second = 60 # None or 60 +queries_per_minute = None # None or 6000 + +try: + if (type(queries_per_second) == int and type(queries_per_minute) == int ): + queries_quota = math.floor(min(queries_per_second, queries_per_minute/60)) + elif (queries_per_second): + queries_quota = math.floor(queries_per_second) + elif (queries_per_minute): + queries_quota = math.floor(queries_per_minute/60) + else: + print("MISSING VALID NUMBER for queries_per_second or queries_per_minute") + print(queries_quota) + +except NameError: + print("MISSING VALUE for queries_per_second or queries_per_minute") \ No newline at end of file diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ff3a0f44..00000000 --- a/tox.ini +++ /dev/null @@ -1,16 +0,0 @@ -[tox] -envlist = - py27,py32,py34,docs - -[testenv] -commands = - nosetests -deps = -rtest_requirements.txt - -[testenv:docs] -basepython = - python2.7 -commands = - sphinx-build -a -E -b html -d docs/_build/doctrees docs docs/_build/html -deps = - Sphinx