diff --git a/owl-bot-staging/google-cloud-testutils/google-cloud-testutils/google-cloud-testutils.txt b/owl-bot-staging/google-cloud-testutils/google-cloud-testutils/google-cloud-testutils.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/.flake8 b/packages/google-cloud-testutils/.flake8 new file mode 100644 index 000000000000..32986c79287a --- /dev/null +++ b/packages/google-cloud-testutils/.flake8 @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 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 +# +# https://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. + +# Generated by synthtool. DO NOT EDIT! +[flake8] +ignore = E203, E231, E266, E501, W503 +exclude = + # Exclude generated code. + **/proto/** + **/gapic/** + **/services/** + **/types/** + *_pb2.py + + # Standard linting exemptions. + **/.nox/** + __pycache__, + .git, + *.pyc, + conf.py diff --git a/packages/google-cloud-testutils/.gitignore b/packages/google-cloud-testutils/.gitignore new file mode 100644 index 000000000000..d083ea1ddc3e --- /dev/null +++ b/packages/google-cloud-testutils/.gitignore @@ -0,0 +1,64 @@ +*.py[cod] +*.sw[op] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +.eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.nox +.cache +.pytest_cache + + +# Mac +.DS_Store + +# JetBrains +.idea + +# VS Code +.vscode + +# emacs +*~ + +# Built documentation +docs/_build +bigquery/docs/generated +docs.metadata + +# Virtual environment +env/ +venv/ + +# Test logs +coverage.xml +*sponge_log.xml + +# System test environment variables. +system_tests/local_test_setup + +# Make sure a generated file isn't accidentally committed. +pylintrc +pylintrc.test diff --git a/packages/google-cloud-testutils/.librarian/state.yaml b/packages/google-cloud-testutils/.librarian/state.yaml new file mode 100644 index 000000000000..b45c9140244d --- /dev/null +++ b/packages/google-cloud-testutils/.librarian/state.yaml @@ -0,0 +1,10 @@ +image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 +libraries: + - id: google-cloud-testutils + version: 1.7.0 + apis: [] + source_roots: + - . + preserve_regex: [] + remove_regex: [] + tag_format: v{version} diff --git a/packages/google-cloud-testutils/.pre-commit-config.yaml b/packages/google-cloud-testutils/.pre-commit-config.yaml new file mode 100644 index 000000000000..1d74695f70b6 --- /dev/null +++ b/packages/google-cloud-testutils/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +# Copyright 2024 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. +# +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml +- repo: https://github.com/psf/black + rev: 23.7.0 + hooks: + - id: black +- repo: https://github.com/pycqa/flake8 + rev: 6.1.0 + hooks: + - id: flake8 diff --git a/packages/google-cloud-testutils/.repo-metadata.json b/packages/google-cloud-testutils/.repo-metadata.json new file mode 100644 index 000000000000..754800595aed --- /dev/null +++ b/packages/google-cloud-testutils/.repo-metadata.json @@ -0,0 +1,14 @@ +{ + "name": "google-cloud-test-utils", + "name_pretty": "Python Test Utils for Google Cloud", + "product_documentation": "", + "client_documentation": "https://github.com/googleapis/python-test-utils", + "issue_tracker": "https://github.com/googleapis/python-test-utils/issues", + "release_level": "preview", + "language": "python", + "library_type": "OTHER", + "repo": "googleapis/python-test-utils", + "distribution_name": "google-cloud-testutils", + "default_version": "", + "codeowner_team": "" +} diff --git a/packages/google-cloud-testutils/CHANGELOG.md b/packages/google-cloud-testutils/CHANGELOG.md new file mode 100644 index 000000000000..75a9655f0b15 --- /dev/null +++ b/packages/google-cloud-testutils/CHANGELOG.md @@ -0,0 +1,131 @@ +# Changelog + +[PyPI History][1] + +[1]: https://pypi.org/project/google-cloud-testutils/#history + +## [1.7.0](https://github.com/googleapis/python-test-utils/compare/v1.6.4...v1.7.0) (2025-10-29) + + +### Features + +* Add Python 3.14 support ([#284](https://github.com/googleapis/python-test-utils/issues/284)) ([3cb8491](https://github.com/googleapis/python-test-utils/commit/3cb8491d67d65d2262aa1b65091ea9b615b583af)) + +## [1.6.4](https://github.com/googleapis/python-test-utils/compare/v1.6.3...v1.6.4) (2025-05-19) + + +### Miscellaneous Chores + +* Force release for testing ([#279](https://github.com/googleapis/python-test-utils/issues/279)) ([7333a49](https://github.com/googleapis/python-test-utils/commit/7333a4904bab456e2274fb1dd2610c868dd331ed)) + +## [1.6.3](https://github.com/googleapis/python-test-utils/compare/v1.6.2...v1.6.3) (2025-05-05) + + +### Miscellaneous Chores + +* Force release for testing ([#277](https://github.com/googleapis/python-test-utils/issues/277)) ([05ad7f1](https://github.com/googleapis/python-test-utils/commit/05ad7f162ff5dd0d03196e9d96eaa88112ddb1b6)) + +## [1.6.2](https://github.com/googleapis/python-test-utils/compare/v1.6.1...v1.6.2) (2025-04-28) + + +### Miscellaneous Chores + +* Force rebuild ([#275](https://github.com/googleapis/python-test-utils/issues/275)) ([40fb90e](https://github.com/googleapis/python-test-utils/commit/40fb90e61f49e8e96b73cfe2c80133a5768b98c0)) + +## [1.6.1](https://github.com/googleapis/python-test-utils/compare/v1.6.0...v1.6.1) (2025-04-22) + + +### Bug Fixes + +* Remove setup.cfg configuration for creating universal wheels ([#272](https://github.com/googleapis/python-test-utils/issues/272)) ([32a23d2](https://github.com/googleapis/python-test-utils/commit/32a23d2e434e133d2b16e4afed6b80890b544968)) + +## [1.6.0](https://github.com/googleapis/python-test-utils/compare/v1.5.0...v1.6.0) (2025-01-30) + + +### Features + +* Add support for `test_utils.__version__` ([#244](https://github.com/googleapis/python-test-utils/issues/244)) ([9655669](https://github.com/googleapis/python-test-utils/commit/9655669de131cd7e0d67b3d6377f49063b5c2acb)) + +## [1.5.0](https://github.com/googleapis/python-test-utils/compare/v1.4.0...v1.5.0) (2024-11-12) + + +### Features + +* Add support for Python 3.13 ([#219](https://github.com/googleapis/python-test-utils/issues/219)) ([37b1ff1](https://github.com/googleapis/python-test-utils/commit/37b1ff1c3473922a57b1858955e0efe94cca1db1)) + +## [1.4.0](https://github.com/googleapis/python-test-utils/compare/v1.3.3...v1.4.0) (2023-11-29) + + +### Features + +* Add support for Python 3.12 ([474961a](https://github.com/googleapis/python-test-utils/commit/474961aa62ec598f8aa658b92032f1843a507116)) + +## [1.3.3](https://github.com/googleapis/python-test-utils/compare/v1.3.2...v1.3.3) (2022-07-10) + + +### Bug Fixes + +* require python 3.7+ ([#107](https://github.com/googleapis/python-test-utils/issues/107)) ([eb41a45](https://github.com/googleapis/python-test-utils/commit/eb41a4549c218f3bed3f57acc78872ae0d0bf2bf)) + +## [1.3.2](https://github.com/googleapis/python-test-utils/compare/v1.3.1...v1.3.2) (2022-06-06) + + +### Documentation + +* fix changelog header to consistent size ([#100](https://github.com/googleapis/python-test-utils/issues/100)) ([a446cdc](https://github.com/googleapis/python-test-utils/commit/a446cdcb4b9e32c7066da82e2e6cae4a6210d85a)) + +## [1.3.1](https://www.github.com/googleapis/python-test-utils/compare/v1.3.0...v1.3.1) (2021-12-07) + + +### Bug Fixes + +* ensure that 'test_utils/py.typed' is packaged ([#76](https://www.github.com/googleapis/python-test-utils/issues/76)) ([4beb565](https://www.github.com/googleapis/python-test-utils/commit/4beb565a4063cb462dc44e469fb91212607016f3)) + +## [1.3.0](https://www.github.com/googleapis/python-test-utils/compare/v1.2.0...v1.3.0) (2021-11-16) + + +### Features + +* add 'py.typed' declaration ([#73](https://www.github.com/googleapis/python-test-utils/issues/73)) ([f8f5f0a](https://www.github.com/googleapis/python-test-utils/commit/f8f5f0a194b2420b2fee1cf88ac50220d3ba1538)) + +## [1.2.0](https://www.github.com/googleapis/python-test-utils/compare/v1.1.0...v1.2.0) (2021-10-18) + + +### Features + +* add support for python 3.10 ([#68](https://www.github.com/googleapis/python-test-utils/issues/68)) ([d93b6a1](https://www.github.com/googleapis/python-test-utils/commit/d93b6a11e3bfade2b29ab90ed3bc2c384beb01cd)) + +## [1.1.0](https://www.github.com/googleapis/python-test-utils/compare/v1.0.0...v1.1.0) (2021-08-30) + + +### Features + +* add 'orchestrate' module ([#54](https://www.github.com/googleapis/python-test-utils/issues/54)) ([ae3da1a](https://www.github.com/googleapis/python-test-utils/commit/ae3da1ab4e7cbf268d6dce60cb467ca7ed6c2c89)) + +## [1.0.0](https://www.github.com/googleapis/python-test-utils/compare/v0.3.0...v1.0.0) (2021-08-02) + + +### ⚠ BREAKING CHANGES + +* drop support for Python 2.7 ([#43](https://www.github.com/googleapis/python-test-utils/issues/43)) ([f5e9c65](https://www.github.com/googleapis/python-test-utils/commit/f5e9c6535481e1ed70fa5e356668e5b0695481e0)) + +## [0.3.0](https://www.github.com/googleapis/python-test-utils/compare/v0.2.1...v0.3.0) (2021-07-07) + + +### Features + +* add Prefixer class to generate and parse resource names ([#39](https://www.github.com/googleapis/python-test-utils/issues/39)) ([865480b](https://www.github.com/googleapis/python-test-utils/commit/865480b5f62bf0db3b14000019a276aea102299d)) + +## [0.2.1](https://www.github.com/googleapis/python-test-utils/compare/v0.2.0...v0.2.1) (2021-06-29) + + +### Bug Fixes + +* use 'six.wraps' vs. 'functools.wraps' ([#37](https://www.github.com/googleapis/python-test-utils/issues/37)) ([701c3a4](https://www.github.com/googleapis/python-test-utils/commit/701c3a41fcf0a63c2b8b689493fa2ae21304511b)) + +## [0.2.0](https://www.github.com/googleapis/python-test-utils/compare/v0.1.0...v0.2.0) (2021-02-22) + + +### Features + +* add lower bound checker ([#8](https://www.github.com/googleapis/python-test-utils/issues/8)) ([5ebac9f](https://www.github.com/googleapis/python-test-utils/commit/5ebac9fb0ad005f8ea947c14dfca6de3c0d2cac9)) diff --git a/packages/google-cloud-testutils/CODE_OF_CONDUCT.md b/packages/google-cloud-testutils/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..039f43681204 --- /dev/null +++ b/packages/google-cloud-testutils/CODE_OF_CONDUCT.md @@ -0,0 +1,95 @@ + +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +This Code of Conduct also applies outside the project spaces when the Project +Steward has a reasonable belief that an individual's behavior may have a +negative impact on the project or its community. + +## Conflict Resolution + +We do not believe that all conflict is bad; healthy debate and disagreement +often yield positive results. However, it is never okay to be disrespectful or +to engage in behavior that violates the project’s code of conduct. + +If you see someone violating the code of conduct, 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. + + +Reports should be directed to *googleapis-stewards@google.com*, the +Project Steward(s) for *Google Cloud Client Libraries*. It is the Project Steward’s duty to +receive and address reported violations of the code of conduct. They will then +work with a committee consisting of representatives from the Open Source +Programs Office and the Google Open Source Strategy team. If for any reason you +are uncomfortable reaching out to the Project Steward, please email +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. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html \ No newline at end of file diff --git a/packages/google-cloud-testutils/LICENSE b/packages/google-cloud-testutils/LICENSE new file mode 100644 index 000000000000..d64569567334 --- /dev/null +++ b/packages/google-cloud-testutils/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/google-cloud-testutils/MANIFEST.in b/packages/google-cloud-testutils/MANIFEST.in new file mode 100644 index 000000000000..b48b40693846 --- /dev/null +++ b/packages/google-cloud-testutils/MANIFEST.in @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# 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 +# +# https://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. + +# Generated by synthtool. DO NOT EDIT! +include README.rst LICENSE +recursive-include test_utils *.json *.proto py.typed +recursive-include tests * +global-exclude *.py[co] +global-exclude __pycache__ + +# Exclude scripts for samples readmegen +prune scripts/readme-gen diff --git a/packages/google-cloud-testutils/README.rst b/packages/google-cloud-testutils/README.rst new file mode 100644 index 000000000000..f2b25ddd0128 --- /dev/null +++ b/packages/google-cloud-testutils/README.rst @@ -0,0 +1,8 @@ +################# +Python Test Utils +################# + +This is a collection of common tools used in system tests of Python client libraries for Google APIs. + +We use `nox `__ to instrument our tests. This package is added to each `nox` session as described in each repository's ``noxfile.py``. + diff --git a/packages/google-cloud-testutils/SECURITY.md b/packages/google-cloud-testutils/SECURITY.md new file mode 100644 index 000000000000..8b58ae9c01ae --- /dev/null +++ b/packages/google-cloud-testutils/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). + +The Google Security Team will respond within 5 working days of your report on g.co/vulnz. + +We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. diff --git a/packages/google-cloud-testutils/mypy.ini b/packages/google-cloud-testutils/mypy.ini new file mode 100644 index 000000000000..8efb6f7241c8 --- /dev/null +++ b/packages/google-cloud-testutils/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +python_version = 3.9 +exclude = tests/unit/resources/ diff --git a/packages/google-cloud-testutils/noxfile.py b/packages/google-cloud-testutils/noxfile.py new file mode 100644 index 000000000000..4828f458638d --- /dev/null +++ b/packages/google-cloud-testutils/noxfile.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# +# 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 +# +# https://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 __future__ import absolute_import +import os +import pathlib +import shutil + +import nox + +# 'update_lower_bounds' is excluded +nox.options.sessions = [ + "lint", + "blacken", + "lint_setup_py", + "mypy", + "unit", + "check_lower_bounds", +] + + +# Error if a python version is missing +nox.options.error_on_missing_interpreters = True + +DEFAULT_PYTHON_VERSION = "3.10" +BLACK_VERSION = "black==23.7.0" +BLACK_PATHS = ["test_utils", "setup.py"] +CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def lint(session): + """Run linters. + + Returns a failure if the linters find linting errors or sufficiently + serious code quality issues. + """ + session.install("flake8", BLACK_VERSION) + session.run( + "black", + "--check", + *BLACK_PATHS, + ) + session.run("flake8", *BLACK_PATHS) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def blacken(session): + """Run black. + + Format code to uniform standard. + """ + session.install(BLACK_VERSION) + session.run( + "black", + *BLACK_PATHS, + ) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def lint_setup_py(session): + """Verify that setup.py is valid (including RST check).""" + session.install("docutils", "pygments") + session.run("python", "setup.py", "check", "--restructuredtext", "--strict") + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def mypy(session): + """Verify type hints are mypy compatible.""" + session.install("-e", ".") + session.install( + "mypy", + "types-mock", + "types-setuptools", + ) + session.run("mypy", "test_utils/", "tests/") + + +@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]) +def unit(session): + constraints_path = str( + CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + ) + + # Install two fake packages for the lower-bound-checker tests + session.install( + "-e", "tests/unit/resources/good_package", "tests/unit/resources/bad_package" + ) + + session.install("pytest", "pytest-cov") + session.install("-e", ".", "-c", constraints_path) + + # Run py.test against the unit tests. + session.run( + "py.test", + "--quiet", + f"--junitxml=unit_{session.python}_sponge_log.xml", + "--cov=test_utils", + "--cov=tests/unit", + "--cov-append", + "--cov-config=.coveragerc", + "--cov-report=", + "--cov-fail-under=0", + os.path.join("tests", "unit"), + *session.posargs, + ) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def check_lower_bounds(session): + """Check lower bounds in setup.py are reflected in constraints file""" + session.install(".") + session.run( + "lower-bound-checker", + "check", + "--package-name", + "google-cloud-testutils", + "--constraints-file", + "testing/constraints-3.7.txt", + ) + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def update_lower_bounds(session): + """Update lower bounds in constraints.txt to match setup.py""" + session.install(".") + session.run( + "lower-bound-checker", + "update", + "--package-name", + "google-cloud-testutils", + "--constraints-file", + "testing/constraints-3.7.txt", + ) diff --git a/packages/google-cloud-testutils/pytest.ini b/packages/google-cloud-testutils/pytest.ini new file mode 100644 index 000000000000..3e849029ca53 --- /dev/null +++ b/packages/google-cloud-testutils/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +filterwarnings = + # treat all warnings as errors + error diff --git a/packages/google-cloud-testutils/renovate.json b/packages/google-cloud-testutils/renovate.json new file mode 100644 index 000000000000..dbdcb7b9f98c --- /dev/null +++ b/packages/google-cloud-testutils/renovate.json @@ -0,0 +1,19 @@ +{ + "extends": [ + "config:base", + ":preserveSemverRanges", + ":disableDependencyDashboard" + ], + "ignorePaths": [ + ".pre-commit-config.yaml", + "tests/unit/resources", + ".kokoro/requirements.txt" + ], + "pip_requirements": { + "fileMatch": [ + "requirements-test.txt", + "samples/[\\S/]*constraints.txt", + "samples/[\\S/]*constraints-test.txt" + ] + } +} diff --git a/packages/google-cloud-testutils/scripts/decrypt-secrets.sh b/packages/google-cloud-testutils/scripts/decrypt-secrets.sh new file mode 100755 index 000000000000..120b0ddc4364 --- /dev/null +++ b/packages/google-cloud-testutils/scripts/decrypt-secrets.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Copyright 2024 Google LLC 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. + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$( dirname "$DIR" ) + +# Work from the project root. +cd $ROOT + +# Prevent it from overriding files. +# We recommend that sample authors use their own service account files and cloud project. +# In that case, they are supposed to prepare these files by themselves. +if [[ -f "testing/test-env.sh" ]] || \ + [[ -f "testing/service-account.json" ]] || \ + [[ -f "testing/client-secrets.json" ]]; then + echo "One or more target files exist, aborting." + exit 1 +fi + +# Use SECRET_MANAGER_PROJECT if set, fallback to cloud-devrel-kokoro-resources. +PROJECT_ID="${SECRET_MANAGER_PROJECT:-cloud-devrel-kokoro-resources}" + +gcloud secrets versions access latest --secret="python-docs-samples-test-env" \ + --project="${PROJECT_ID}" \ + > testing/test-env.sh +gcloud secrets versions access latest \ + --secret="python-docs-samples-service-account" \ + --project="${PROJECT_ID}" \ + > testing/service-account.json +gcloud secrets versions access latest \ + --secret="python-docs-samples-client-secrets" \ + --project="${PROJECT_ID}" \ + > testing/client-secrets.json diff --git a/packages/google-cloud-testutils/scripts/readme-gen/readme_gen.py b/packages/google-cloud-testutils/scripts/readme-gen/readme_gen.py new file mode 100644 index 000000000000..8f5e248a0da1 --- /dev/null +++ b/packages/google-cloud-testutils/scripts/readme-gen/readme_gen.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python + +# Copyright 2024 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. + +"""Generates READMEs using configuration defined in yaml.""" + +import argparse +import io +import os +import subprocess + +import jinja2 +import yaml + + +jinja_env = jinja2.Environment( + trim_blocks=True, + loader=jinja2.FileSystemLoader( + os.path.abspath(os.path.join(os.path.dirname(__file__), "templates")) + ), + autoescape=True, +) + +README_TMPL = jinja_env.get_template("README.tmpl.rst") + + +def get_help(file): + return subprocess.check_output(["python", file, "--help"]).decode() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("source") + parser.add_argument("--destination", default="README.rst") + + args = parser.parse_args() + + source = os.path.abspath(args.source) + root = os.path.dirname(source) + destination = os.path.join(root, args.destination) + + jinja_env.globals["get_help"] = get_help + + with io.open(source, "r") as f: + config = yaml.load(f) + + # This allows get_help to execute in the right directory. + os.chdir(root) + + output = README_TMPL.render(config) + + with io.open(destination, "w") as f: + f.write(output) + + +if __name__ == "__main__": + main() diff --git a/packages/google-cloud-testutils/scripts/readme-gen/templates/README.tmpl.rst b/packages/google-cloud-testutils/scripts/readme-gen/templates/README.tmpl.rst new file mode 100644 index 000000000000..4fd239765b0a --- /dev/null +++ b/packages/google-cloud-testutils/scripts/readme-gen/templates/README.tmpl.rst @@ -0,0 +1,87 @@ +{# The following line is a lie. BUT! Once jinja2 is done with it, it will + become truth! #} +.. This file is automatically generated. Do not edit this file directly. + +{{product.name}} Python Samples +=============================================================================== + +.. image:: https://gstatic.com/cloudssh/images/open-btn.png + :target: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&page=editor&open_in_editor={{folder}}/README.rst + + +This directory contains samples for {{product.name}}. {{product.description}} + +{{description}} + +.. _{{product.name}}: {{product.url}} + +{% if required_api_url %} +To run the sample, you need to enable the API at: {{required_api_url}} +{% endif %} + +{% if required_role %} +To run the sample, you need to have `{{required_role}}` role. +{% endif %} + +{{other_required_steps}} + +{% if setup %} +Setup +------------------------------------------------------------------------------- + +{% for section in setup %} + +{% include section + '.tmpl.rst' %} + +{% endfor %} +{% endif %} + +{% if samples %} +Samples +------------------------------------------------------------------------------- + +{% for sample in samples %} +{{sample.name}} ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +{% if not sample.hide_cloudshell_button %} +.. image:: https://gstatic.com/cloudssh/images/open-btn.png + :target: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&page=editor&open_in_editor={{folder}}/{{sample.file}},{{folder}}/README.rst +{% endif %} + + +{{sample.description}} + +To run this sample: + +.. code-block:: bash + + $ python {{sample.file}} +{% if sample.show_help %} + + {{get_help(sample.file)|indent}} +{% endif %} + + +{% endfor %} +{% endif %} + +{% if cloud_client_library %} + +The client library +------------------------------------------------------------------------------- + +This sample uses the `Google Cloud Client Library for Python`_. +You can read the documentation for more details on API usage and use GitHub +to `browse the source`_ and `report issues`_. + +.. _Google Cloud Client Library for Python: + https://googlecloudplatform.github.io/google-cloud-python/ +.. _browse the source: + https://github.com/GoogleCloudPlatform/google-cloud-python +.. _report issues: + https://github.com/GoogleCloudPlatform/google-cloud-python/issues + +{% endif %} + +.. _Google Cloud SDK: https://cloud.google.com/sdk/ \ No newline at end of file diff --git a/packages/google-cloud-testutils/scripts/readme-gen/templates/auth.tmpl.rst b/packages/google-cloud-testutils/scripts/readme-gen/templates/auth.tmpl.rst new file mode 100644 index 000000000000..1446b94a5e3a --- /dev/null +++ b/packages/google-cloud-testutils/scripts/readme-gen/templates/auth.tmpl.rst @@ -0,0 +1,9 @@ +Authentication +++++++++++++++ + +This sample requires you to have authentication setup. Refer to the +`Authentication Getting Started Guide`_ for instructions on setting up +credentials for applications. + +.. _Authentication Getting Started Guide: + https://cloud.google.com/docs/authentication/getting-started diff --git a/packages/google-cloud-testutils/scripts/readme-gen/templates/auth_api_key.tmpl.rst b/packages/google-cloud-testutils/scripts/readme-gen/templates/auth_api_key.tmpl.rst new file mode 100644 index 000000000000..11957ce2714a --- /dev/null +++ b/packages/google-cloud-testutils/scripts/readme-gen/templates/auth_api_key.tmpl.rst @@ -0,0 +1,14 @@ +Authentication +++++++++++++++ + +Authentication for this service is done via an `API Key`_. To obtain an API +Key: + +1. Open the `Cloud Platform Console`_ +2. Make sure that billing is enabled for your project. +3. From the **Credentials** page, create a new **API Key** or use an existing + one for your project. + +.. _API Key: + https://developers.google.com/api-client-library/python/guide/aaa_apikeys +.. _Cloud Console: https://console.cloud.google.com/project?_ diff --git a/packages/google-cloud-testutils/scripts/readme-gen/templates/install_deps.tmpl.rst b/packages/google-cloud-testutils/scripts/readme-gen/templates/install_deps.tmpl.rst new file mode 100644 index 000000000000..6f069c6c87a5 --- /dev/null +++ b/packages/google-cloud-testutils/scripts/readme-gen/templates/install_deps.tmpl.rst @@ -0,0 +1,29 @@ +Install Dependencies +++++++++++++++++++++ + +#. Clone python-docs-samples and change directory to the sample directory you want to use. + + .. code-block:: bash + + $ git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git + +#. Install `pip`_ and `virtualenv`_ if you do not already have them. You may want to refer to the `Python Development Environment Setup Guide`_ for Google Cloud Platform for instructions. + + .. _Python Development Environment Setup Guide: + https://cloud.google.com/python/setup + +#. Create a virtualenv. Samples are compatible with Python 3.7+. + + .. code-block:: bash + + $ virtualenv env + $ source env/bin/activate + +#. Install the dependencies needed to run the samples. + + .. code-block:: bash + + $ pip install -r requirements.txt + +.. _pip: https://pip.pypa.io/ +.. _virtualenv: https://virtualenv.pypa.io/ diff --git a/packages/google-cloud-testutils/scripts/readme-gen/templates/install_portaudio.tmpl.rst b/packages/google-cloud-testutils/scripts/readme-gen/templates/install_portaudio.tmpl.rst new file mode 100644 index 000000000000..5ea33d18c00c --- /dev/null +++ b/packages/google-cloud-testutils/scripts/readme-gen/templates/install_portaudio.tmpl.rst @@ -0,0 +1,35 @@ +Install PortAudio ++++++++++++++++++ + +Install `PortAudio`_. This is required by the `PyAudio`_ library to stream +audio from your computer's microphone. PyAudio depends on PortAudio for cross-platform compatibility, and is installed differently depending on the +platform. + +* For Mac OS X, you can use `Homebrew`_:: + + brew install portaudio + + **Note**: if you encounter an error when running `pip install` that indicates + it can't find `portaudio.h`, try running `pip install` with the following + flags:: + + pip install --global-option='build_ext' \ + --global-option='-I/usr/local/include' \ + --global-option='-L/usr/local/lib' \ + pyaudio + +* For Debian / Ubuntu Linux:: + + apt-get install portaudio19-dev python-all-dev + +* Windows may work without having to install PortAudio explicitly (it will get + installed with PyAudio). + +For more details, see the `PyAudio installation`_ page. + + +.. _PyAudio: https://people.csail.mit.edu/hubert/pyaudio/ +.. _PortAudio: http://www.portaudio.com/ +.. _PyAudio installation: + https://people.csail.mit.edu/hubert/pyaudio/#downloads +.. _Homebrew: http://brew.sh diff --git a/packages/google-cloud-testutils/setup.py b/packages/google-cloud-testutils/setup.py new file mode 100644 index 000000000000..9b7b0c972bbc --- /dev/null +++ b/packages/google-cloud-testutils/setup.py @@ -0,0 +1,81 @@ +# Copyright 2017 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 io +import os +import re +import setuptools # type: ignore + +version = None + +PACKAGE_ROOT = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(PACKAGE_ROOT, "test_utils/version.py")) as fp: + version_candidates = re.findall(r"(?<=\")\d+.\d+.\d+(?=\")", fp.read()) + assert len(version_candidates) == 1 + version = version_candidates[0] + +package_root = os.path.abspath(os.path.dirname(__file__)) + +readme_filename = os.path.join(package_root, "README.rst") +with io.open(readme_filename, encoding="utf-8") as readme_file: + readme = readme_file.read() + +scripts = ( + ["lower-bound-checker=test_utils.lower_bound_checker.lower_bound_checker:main"], +) + +packages = [ + package + for package in setuptools.find_namespace_packages() + if package.startswith("test_utils") +] + +setuptools.setup( + name="google-cloud-testutils", + version=version, + long_description=readme, + author="Google LLC", + author_email="googleapis-packages@google.com", + license="Apache 2.0", + url="https://github.com/googleapis/python-test-utils", + packages=packages, + entry_points={"console_scripts": scripts}, + platforms="Posix; MacOS X; Windows", + include_package_data=True, + install_requires=( + "google-auth >= 0.4.0", + "click>=7.0.0", + "packaging>=19.0", + "importlib_metadata>=1.0.0; python_version<'3.8'", + ), + python_requires=">=3.7", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Internet", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + zip_safe=False, +) diff --git a/packages/google-cloud-testutils/test_utils/__init__.py b/packages/google-cloud-testutils/test_utils/__init__.py new file mode 100644 index 000000000000..8a4b251003e2 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2025 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 .version import __version__ # noqa: F401 diff --git a/packages/google-cloud-testutils/test_utils/imports.py b/packages/google-cloud-testutils/test_utils/imports.py new file mode 100644 index 000000000000..46489b3e7904 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/imports.py @@ -0,0 +1,38 @@ +# Copyright 2019 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 builtins +from unittest import mock + + +def maybe_fail_import(predicate): + """Create and return a patcher that conditionally makes an import fail. + + Args: + predicate (Callable[[...], bool]): A callable that, if it returns `True`, + triggers an `ImportError`. It must accept the same arguments as the + built-in `__import__` function. + https://docs.python.org/3/library/functions.html#__import__ + + Returns: + A mock patcher object that can be used to enable patched import behavior. + """ + orig_import = builtins.__import__ + + def custom_import(name, globals=None, locals=None, fromlist=(), level=0): + if predicate(name, globals, locals, fromlist, level): + raise ImportError + return orig_import(name, globals, locals, fromlist, level) + + return mock.patch.object(builtins, "__import__", new=custom_import) diff --git a/packages/google-cloud-testutils/test_utils/lower_bound_checker/__init__.py b/packages/google-cloud-testutils/test_utils/lower_bound_checker/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/test_utils/lower_bound_checker/lower_bound_checker.py b/packages/google-cloud-testutils/test_utils/lower_bound_checker/lower_bound_checker.py new file mode 100644 index 000000000000..b6594ded1700 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/lower_bound_checker/lower_bound_checker.py @@ -0,0 +1,271 @@ +# Copyright 2021 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 click +from pathlib import Path +from typing import List, Tuple, Set +import sys + +from packaging.requirements import Requirement +from packaging.version import Version + +if sys.version_info < (3, 8): + import importlib_metadata as metadata +else: + import importlib.metadata as metadata + + +def _get_package_requirements(package_name: str) -> List[Requirement]: + """ + Get a list of all requirements and extras declared by this package. + The package must already be installed in the environment. + + Args: + package_name (str): The name of the package. + + Returns: + List[packaging.requirements.Requirement]: A list of package requirements and extras. + """ + requirements = [] + distribution = metadata.distribution(package_name) + if distribution.requires: + requirements = [Requirement(str(r)) for r in distribution.requires] + return requirements + + +def _parse_requirements_file(requirements_file: str) -> List[Requirement]: + """ + Get a list of requirements found in a requirements file. + + Args: + requirements_file (str): Path to a requirements file. + + Returns: + List[Requirement]: A list of requirements. + """ + requirements = [] + + with Path(requirements_file).open() as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + requirements.append(Requirement(line)) + + return requirements + + +def _get_pinned_versions( + ctx: click.Context, requirements: List[Requirement] +) -> Set[Tuple[str, Version]]: + """Turn a list of requirements into a set of (package name, Version) tuples. + + The requirements are all expected to pin explicitly to one version. + Other formats will result in an error. + + {("requests", Version("1.25.0"), ("google-auth", Version("1.0.0")} + + Args: + ctx (click.Context): The current click context. + requirements (List[Requirement]): A list of requirements. + + Returns: + Set[Tuple[str, Version]]: Tuples of the package name and Version. + """ + constraints = set() + + invalid_requirements = [] + + for constraint in requirements: + spec_set = list(constraint.specifier) + if len(spec_set) != 1: + invalid_requirements.append(constraint.name) + else: + if spec_set[0].operator != "==": + invalid_requirements.append(constraint.name) + else: + constraints.add((constraint.name, Version(spec_set[0].version))) + + if invalid_requirements: + ctx.fail( + f"These requirements are not pinned to one version: {invalid_requirements}" + ) + + return constraints + + +class IndeterminableLowerBound(Exception): + pass + + +def _lower_bound(requirement: Requirement) -> str: + """ + Given a requirement, determine the lowest version that fulfills the requirement. + The lower bound can be determined for a requirement only if it is one of these + formats: + + foo==1.2.0 + foo>=1.2.0 + foo>=1.2.0, <2.0.0dev + foo<2.0.0dev, >=1.2.0 + + Args: + requirement (Requirement): A requirement to parse + + Returns: + str: The lower bound for the requirement. + """ + spec_set = list(requirement.specifier) + + # sort by operator: <, then >= + spec_set.sort(key=lambda x: x.operator) + + if len(spec_set) == 1: + # foo==1.2.0 + if spec_set[0].operator == "==": + return spec_set[0].version + # foo>=1.2.0 + elif spec_set[0].operator == ">=": + return spec_set[0].version + # foo<2.0.0, >=1.2.0 or foo>=1.2.0, <2.0.0 + elif len(spec_set) == 2: + if spec_set[0].operator == "<" and spec_set[1].operator == ">=": + return spec_set[1].version + + raise IndeterminableLowerBound( + f"Lower bound could not be determined for {requirement.name}" + ) + + +def _get_package_lower_bounds( + ctx: click.Context, requirements: List[Requirement] +) -> Set[Tuple[str, Version]]: + """Get a set of tuples ('package_name', Version('1.0.0')) from a + list of Requirements. + + Args: + ctx (click.Context): The current click context. + requirements (List[Requirement]): A list of requirements. + + Returns: + Set[Tuple[str, Version]]: A set of (package_name, lower_bound) + tuples. + """ + bad_package_lower_bounds = [] + package_lower_bounds = set() + + for req in requirements: + try: + version = _lower_bound(req) + package_lower_bounds.add((req.name, Version(version))) + except IndeterminableLowerBound: + bad_package_lower_bounds.append(req.name) + + if bad_package_lower_bounds: + ctx.fail( + f"setup.py is missing explicit lower bounds for the following packages: {str(bad_package_lower_bounds)}" + ) + else: + return package_lower_bounds + + +@click.group() +def main(): + pass + + +@main.command() +@click.option("--package-name", required=True, help="Name of the package.") +@click.option("--constraints-file", required=True, help="Path to constraints file.") +@click.pass_context +def update(ctx: click.Context, package_name: str, constraints_file: str) -> None: + """Create a constraints file with lower bounds for package-name. + + If the constraints file already exists the contents will be overwritten. + """ + requirements = _get_package_requirements(package_name) + requirements.sort(key=lambda x: x.name) + + package_lower_bounds = list(_get_package_lower_bounds(ctx, requirements)) + package_lower_bounds.sort(key=lambda x: x[0]) + + constraints = [f"{name}=={version}" for name, version in package_lower_bounds] + Path(constraints_file).write_text("\n".join(constraints)) + + +@main.command() +@click.option("--package-name", required=True, help="Name of the package.") +@click.option("--constraints-file", required=True, help="Path to constraints file.") +@click.pass_context +def check(ctx: click.Context, package_name: str, constraints_file: str): + """Check that the constraints-file pins to the lower bound specified in package-name's + setup.py for each requirement. + + Requirements: + + 1. The setup.py pins every requirement in one of the following formats: + + * foo==1.2.0 + + * foo>=1.2.0 + + * foo>=1.2.0, <2.0.0dev + + * foo<2.0.0dev, >=1.2.0 + + 2. The constraints file pins every requirement to a single version: + + * foo==1.2.0 + + 3. package-name is already installed in the environment. + """ + + package_requirements = _get_package_requirements(package_name) + constraints = _parse_requirements_file(constraints_file) + + package_lower_bounds = _get_package_lower_bounds(ctx, package_requirements) + constraints_file_versions = _get_pinned_versions(ctx, constraints) + + # Look for dependencies in setup.py that are missing from constraints.txt + package_names = {x[0] for x in package_lower_bounds} + constraint_names = {x[0] for x in constraints_file_versions} + missing_from_constraints = package_names - constraint_names + + if missing_from_constraints: + ctx.fail( + ( + f"The following packages are declared as a requirement or extra" + f"in setup.py but were not found in {constraints_file}: {str(missing_from_constraints)}" + ) + ) + + # We use .issuperset() instead of == because there may be additional entries + # in constraints.txt (e.g., test only requirements) + if not constraints_file_versions.issuperset(package_lower_bounds): + first_line = f"The following packages have different versions {package_name}'s setup.py and {constraints_file}" + error_msg = [first_line, "-" * (7 + len(first_line))] + + difference = package_lower_bounds - constraints_file_versions + constraints_dict = dict(constraints_file_versions) + + for req, setup_py_version in difference: + error_msg.append( + f"'{req}' lower bound is {setup_py_version} in setup.py but constraints file has {constraints_dict[req]}" + ) + ctx.fail("\n".join(error_msg)) + + click.secho("All good!", fg="green") + + +if __name__ == "__main__": + main() diff --git a/packages/google-cloud-testutils/test_utils/orchestrate.py b/packages/google-cloud-testutils/test_utils/orchestrate.py new file mode 100644 index 000000000000..a6fd9a770a82 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/orchestrate.py @@ -0,0 +1,446 @@ +# Copyright 2018 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 +# +# https://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 itertools +import math +import queue +import sys +import threading +import tokenize + + +def orchestrate(*tests, **kwargs): + """ + Orchestrate a deterministic concurrency test. + + Runs test functions in separate threads, with each thread taking turns running up + until predefined syncpoints in a deterministic order. All possible orderings are + tested. + + Most of the time, we try to use logic, best practices, and static analysis to insure + correct operation of concurrent code. Sometimes our powers of reasoning fail us and, + either through non-determistic stress testing or running code in production, a + concurrent bug is discovered. When this occurs, we'd like to have a regression test + to insure we've understood the problem and implemented a correct solution. + `orchestrate` provides a means of deterministically testing concurrent code so we + can write robust regression tests for complex concurrent scenarios. + + `orchestrate` runs each passed in test function in its own thread. Threads then + "take turns" running. Turns are defined by setting syncpoints in the code under + test, using comments containing "pragma: SYNCPOINT". `orchestrate` will scan the + code under test and add syncpoints where it finds these comments. + + For example, let's say you have the following code in production:: + + def hither_and_yon(destination): + hither(destination) + yon(destination) + + You've found there's a concurrency bug when two threads execute this code with the + same argument, and you think that by adding a syncpoint between the calls to + `hither` and `yon` you can reproduce the problem in a regression test. First add a + comment with "pragma: SYNCPOINT" to the code under test:: + + def hither_and_yon(destination): + hither(destination) # pragma: SYNCPOINT + yon(destination) + + When testing with orchestrate, there will now be a syncpoint, or a pause, after the + call to `hither` and before the call to `yon`. Now you can write a test to exercise + `hither_and_yon` running in parallel:: + + from unittest import mock + from tests.unit import orchestrate + + from myorg.myproj.sales import travel + + def test_concurrent_hither_and_yon(): + + def test_hither_and_yon(): + assert something + travel.hither_and_yon("Raleigh") + assert something_else + + counts = orchestrate.orchestrate(test_hither_and_yon, test_hither_and_yon) + assert counts == (2, 2) + + What `orchestrate` will do now is take each of the two test functions passed in + (actually the same function, twice, in this case), run them serially, and count the + number of turns it takes to run each test to completion. In this example, it will + take two turns for each test: one turn to start the thread and execute up until the + syncpoint, and then another turn to execute from the syncpoint to the end of the + test. The number of turns will always be one greater than the number of syncpoints + encountered when executing the test. + + Once the counts have been taken, `orchestrate` will construct a test sequence that + represents all of the turns taken by the passed in tests, with each value in the + sequence representing the index of the test whose turn it is in the sequence. In + this example, then, it would produce:: + + [0, 0, 1, 1] + + This represents the first test taking both of its turns, followed by the second test + taking both of its turns. At this point this scenario has already been tested, + because this is what was run to produce the counts and the initial test sequence. + Now `orchestrate` will run all of the remaining scenarios by finding all the + permutations of the test sequence and executing those, in turn:: + + [0, 1, 0, 1] + [0, 1, 1, 0] + [1, 0, 0, 1] + [1, 0, 1, 0] + [1, 1, 0, 0] + + You'll notice in our example that since both test functions are actually the same + function, that although it tested 6 scenarios there are effectively only really 3 + unique scenarios. For the time being, though, `orchestrate` doesn't attempt to + detect this condition or optimize for it. + + There are some performance considerations that should be taken into account when + writing tests. The number of unique test sequences grows quite quickly with the + number of turns taken by the functions under test. Our simple example with two + threads each taking two turns, only yielded 6 scenarios, but two threads each taking + 6 turns, for example, yields 924 scenarios. Add another six step thread, for a total + of three threads, and now you have over 17 thousand scenarios. In general, use the + least number of steps/threads you can get away with and still expose the behavior + you want to correct. + + For the same reason as above, it is recommended that if you have many concurrent + tests, that you name your syncpoints so that you're not accidentally using + syncpoints intended for other tests, as this will add steps to your tests. While + it's not problematic from a testing standpoint to have extra steps in your tests, it + can use computing resources unnecessarily. A name can be added to any syncpoint + after the `SYNCPOINT` keyword in the pragma definition:: + + def hither_and_yon(destination): + hither(destination) # pragma: SYNCPOINT hither and yon + yon(destination) + + In your test, then, pass that name to `orchestrate` to cause it to use only + syncpoints with that name:: + + orchestrate.orchestrate( + test_hither_and_yon, test_hither_and_yon, name="hither and yon" + ) + + As soon as any error or failure is detected, no more scenarios are run + and that error is propagated to the main thread. + + One limitation of `orchestrate` is that it cannot really be used with `coverage`, + since both tools use `sys.set_trace`. Any code that needs verifiable test coverage + should have additional tests that do not use `orchestrate`, since code that is run + under orchestrate will not show up in a coverage report generated by `coverage`. + + Args: + tests (Tuple[Callable]): Test functions to be run. These functions will not be + called with any arguments, so they must not have any required arguments. + name (Optional[str]): Only use syncpoints with the given name. If omitted, only + unnamed syncpoints will be used. + + Returns: + Tuple[int]: A tuple of the count of the number turns for test passed in. Can be + used a sanity check in tests to make sure you understand what's actually + happening during a test. + """ + name = kwargs.pop("name", None) + if kwargs: + raise TypeError( + "Unexpected keyword arguments: {}".format(", ".join(kwargs.keys())) + ) + + # Produce an initial test sequence. The fundamental question we're always trying to + # answer is "whose turn is it?" First we'll find out how many "turns" each test + # needs to complete when run serially and use that to construct a sequence of + # indexes. When a test's index appears in the sequence, it is that test's turn to + # run. We'll start by constructing a sequence that would run each test through to + # completion serially, one after the other. + test_sequence = [] + counts = [] + for index, test in enumerate(tests): + thread = _TestThread(test, name) + for count in itertools.count(1): # pragma: NO BRANCH + # Pragma is required because loop never finishes naturally. + thread.go() + if thread.finished: + break + + counts.append(count) + test_sequence += [index] * count + + # Now we can take that initial sequence and generate all of its permutations, + # running each one to try to uncover concurrency bugs + sequences = iter(_permutations(test_sequence)) + + # We already tested the first sequence getting our counts, so we can discard it + next(sequences) + + # Test each sequence + for test_sequence in sequences: + threads = [_TestThread(test, name) for test in tests] + try: + for index in test_sequence: + threads[index].go() + + # Its possible for number of turns to vary from one test run to the other, + # especially if there is some undiscovered concurrency bug. Go ahead and + # finish running each test to completion, if not already complete. + for thread in threads: + while not thread.finished: + thread.go() + + except Exception: + # If an exception occurs, we still need to let any threads that are still + # going finish up. Additional exceptions are silently ignored. + for thread in threads: + thread.finish() + raise + + return tuple(counts) + + +_local = threading.local() + + +class _Conductor: + """Coordinate communication between main thread and a test thread. + + Two way communicaton is maintained between the main thread and a test thread using + two synchronized queues (`queue.Queue`) each with a size of one. + """ + + def __init__(self): + self._notify = queue.Queue(1) + self._go = queue.Queue(1) + + def notify(self): + """Called from test thread to let us know it's finished or is ready for its next + turn.""" + self._notify.put(None) + + def standby(self): + """Called from test thread in order to block until told to go.""" + self._go.get() + + def wait(self): + """Called from main thread to wait for test thread to either get to the + next syncpoint or finish.""" + self._notify.get() + + def go(self): + """Called from main thread to tell test thread to go.""" + self._go.put(None) + + +_SYNCPOINTS = {} +"""Dict[str, Dict[str, Set[int]]]: Dict mapping source fileneme to a dict mapping +syncpoint name to set of line numbers where syncpoints with that name occur in the +source file. +""" + + +def _get_syncpoints(filename): + """Find syncpoints in a source file. + + Does a simple tokenization of the source file, looking for comments with "pragma: + SYNCPOINT", and populates _SYNCPOINTS using the syncpoint name and line number in + the source file. + """ + _SYNCPOINTS[filename] = syncpoints = {} + + # Use tokenize to find pragma comments + with open(filename, "r") as pyfile: + tokens = tokenize.generate_tokens(pyfile.readline) + for type, value, start, end, line in tokens: + if type == tokenize.COMMENT and "pragma: SYNCPOINT" in value: + name = value.split("SYNCPOINT", 1)[1].strip() + if not name: + name = None + + if name not in syncpoints: + syncpoints[name] = set() + + lineno, column = start + syncpoints[name].add(lineno) + + +class _TestThread: + """A thread for a test function.""" + + thread = None + finished = False + error = None + at_syncpoint = False + + def __init__(self, test, name): + self.test = test + self.name = name + self.conductor = _Conductor() + + def _run(self): + sys.settrace(self._trace) + _local.conductor = self.conductor + try: + self.test() + except Exception as error: + self.error = error + finally: + self.finished = True + self.conductor.notify() + + def _sync(self): + # Tell main thread we're finished, for now + self.conductor.notify() + + # Wait for the main thread to tell us to go again + self.conductor.standby() + + def _trace(self, frame, event, arg): + """Argument to `sys.settrace`. + + Handles frames during test run, syncing at syncpoints, when found. + + Returns: + `None` if no more tracing is required for the function call, `self._trace` + if tracing should continue. + """ + if self.at_syncpoint: + # We hit a syncpoint on the previous call, so now we sync. + self._sync() + self.at_syncpoint = False + + filename = frame.f_globals.get("__file__") + if not filename: + # Can't trace code without a source file + return + + if filename.endswith(".pyc"): + filename = filename[:-1] + + if filename not in _SYNCPOINTS: + _get_syncpoints(filename) + + syncpoints = _SYNCPOINTS[filename].get(self.name) + if not syncpoints: + # This file doesn't contain syncpoints, don't continue to trace + return + + # We've hit a syncpoint. Execute whatever line the syncpoint is on and then + # sync next time this gets called. + if frame.f_lineno in syncpoints: + self.at_syncpoint = True + + return self._trace + + def go(self): + if self.finished: + return + + if self.thread is None: + self.thread = threading.Thread(target=self._run) + self.thread.start() + + else: + self.conductor.go() + + self.conductor.wait() + + if self.error: + raise self.error + + def finish(self): + while not self.finished: + try: + self.go() + except Exception: + pass + + +class _permutations: + """Generates a sequence of all permutations of `sequence`. + + Permutations are returned in lexicographic order using the "Generation in + lexicographic order" algorithm described in `the Wikipedia article on "Permutation" + `_. + + This implementation differs significantly from `itertools.permutations` in that the + value of individual elements is taken into account, thus eliminating redundant + orderings that would be produced by `itertools.permutations`. + + Args: + sequence (Sequence[Any]): Sequence must be finite and orderable. + + Returns: + Sequence[Sequence[Any]]: Set of all permutations of `sequence`. + """ + + def __init__(self, sequence): + self._start = tuple(sorted(sequence)) + + def __len__(self): + """Compute the number of permutations. + + Let the number of elements in a sequence N and the number of repetitions for + individual members of the sequence be n1, n2, ... nx. The number of unique + permutations is: N! / n1! / n2! / ... / nx!. + + For example, let `sequence` be [1, 2, 3, 1, 2, 3, 1, 2, 3]. The number of unique + permutations is: 9! / 3! / 3! / 3! = 1680. + + See: "Permutations of multisets" in `the Wikipedia article on "Permutation" + `_. + """ + repeats = [len(list(group)) for value, group in itertools.groupby(self._start)] + length = math.factorial(len(self._start)) + for repeat in repeats: + length /= math.factorial(repeat) + + return int(length) + + def __iter__(self): + """Iterate over permutations. + + See: "Generation in lexicographic order" algorithm described in `the Wikipedia + article on "Permutation" `_. + """ + current = list(self._start) + size = len(current) + + while True: + yield tuple(current) + + # 1. Find the largest index i such that a[i] < a[i + 1]. + for i in range(size - 2, -1, -1): + if current[i] < current[i + 1]: + break + + else: + # If no such index exists, the permutation is the last permutation. + return + + # 2. Find the largest index j greater than i such that a[i] < a[j]. + for j in range(size - 1, i, -1): + if current[i] < current[j]: + break + + else: # pragma: NO COVER + raise RuntimeError("Broken algorithm") + + # 3. Swap the value of a[i] with that of a[j]. + temp = current[i] + current[i] = current[j] + current[j] = temp + + # 4. Reverse the sequence from a[i + 1] up to and including the final + # element a[n]. + current = current[: i + 1] + list(reversed(current[i + 1 :])) diff --git a/packages/google-cloud-testutils/test_utils/prefixer.py b/packages/google-cloud-testutils/test_utils/prefixer.py new file mode 100644 index 000000000000..89d1e8e4423a --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/prefixer.py @@ -0,0 +1,82 @@ +# Copyright 2021 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 datetime +import random +import re + +from typing import Union + +_RESOURCE_DATE_FORMAT = "%Y%m%d%H%M%S" +_RESOURCE_DATE_LENGTH = 4 + 2 + 2 + 2 + 2 + 2 +_RE_SEPARATORS = re.compile(r"[/\-\\_]") + + +def _common_prefix(repo, relative_dir, separator="_"): + repo = _RE_SEPARATORS.sub(separator, repo) + relative_dir = _RE_SEPARATORS.sub(separator, relative_dir) + return f"{repo}{separator}{relative_dir}" + + +class Prefixer(object): + """Create/manage resource IDs for system testing. + + Usage: + + Creating resources: + + >>> import test_utils.prefixer + >>> prefixer = test_utils.prefixer.Prefixer("python-bigquery", "samples/snippets") + >>> dataset_id = prefixer.create_prefix() + "my_sample" + + Cleaning up resources: + + >>> @pytest.fixture(scope="session", autouse=True) + ... def cleanup_datasets(bigquery_client: bigquery.Client): + ... for dataset in bigquery_client.list_datasets(): + ... if prefixer.should_cleanup(dataset.dataset_id): + ... bigquery_client.delete_dataset( + ... dataset, delete_contents=True, not_found_ok=True + """ + + def __init__( + self, repo, relative_dir, separator="_", cleanup_age=datetime.timedelta(days=1) + ): + self._separator = separator + self._cleanup_age = cleanup_age + self._prefix = _common_prefix(repo, relative_dir, separator=separator) + + def create_prefix(self) -> str: + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + timestamp = now.strftime(_RESOURCE_DATE_FORMAT) + random_string = hex(random.randrange(0x1000000))[2:] + return f"{self._prefix}{self._separator}{timestamp}{self._separator}{random_string}" + + def _name_to_date(self, resource_name: str) -> Union[datetime.datetime, None]: + start_date = len(self._prefix) + len(self._separator) + date_string = resource_name[start_date : start_date + _RESOURCE_DATE_LENGTH] + try: + parsed_date = datetime.datetime.strptime(date_string, _RESOURCE_DATE_FORMAT) + return parsed_date + except ValueError: + return None + + def should_cleanup(self, resource_name: str) -> bool: + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + yesterday = now - self._cleanup_age + if not resource_name.startswith(self._prefix): + return False + + created_date = self._name_to_date(resource_name) + return created_date is not None and created_date < yesterday diff --git a/packages/google-cloud-testutils/test_utils/py.typed b/packages/google-cloud-testutils/test_utils/py.typed new file mode 100644 index 000000000000..7f6cc03c8009 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/py.typed @@ -0,0 +1,2 @@ +# Marker file for PEP 561. +# The test_utils package uses inline types. diff --git a/packages/google-cloud-testutils/test_utils/retry.py b/packages/google-cloud-testutils/test_utils/retry.py new file mode 100644 index 000000000000..a84a9003f99f --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/retry.py @@ -0,0 +1,228 @@ +# Copyright 2016 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 functools +import time + +MAX_TRIES = 4 +DELAY = 1 +BACKOFF = 2 + + +def _retry_all(_): + """Retry all caught exceptions.""" + return True + + +class BackoffFailed(Exception): + """Retry w/ backoffs did not complete successfully.""" + + +class RetryBase(object): + """Base for retrying calling a decorated function w/ exponential backoff. + + :type max_tries: int + :param max_tries: Number of times to try (not retry) before giving up. + + :type delay: int + :param delay: Initial delay between retries in seconds. + + :type backoff: int + :param backoff: Backoff multiplier e.g. value of 2 will double the + delay each retry. + + :type logger: logging.Logger instance + :param logger: Logger to use. If None, print. + """ + + def __init__(self, max_tries=MAX_TRIES, delay=DELAY, backoff=BACKOFF, logger=None): + self.max_tries = max_tries + self.delay = delay + self.backoff = backoff + self.logger = logger.warning if logger else print + + +class RetryErrors(RetryBase): + """Decorator for retrying given exceptions in testing. + + :type exception: Exception or tuple of Exceptions + :param exception: The exception to check or may be a tuple of + exceptions to check. + + :type error_predicate: function, takes caught exception, returns bool + :param error_predicate: Predicate evaluating whether to retry after a + caught exception. + + :type max_tries: int + :param max_tries: Number of times to try (not retry) before giving up. + + :type delay: int + :param delay: Initial delay between retries in seconds. + + :type backoff: int + :param backoff: Backoff multiplier e.g. value of 2 will double the + delay each retry. + + :type logger: logging.Logger instance + :param logger: Logger to use. If None, print. + """ + + def __init__( + self, + exception, + error_predicate=_retry_all, + max_tries=MAX_TRIES, + delay=DELAY, + backoff=BACKOFF, + logger=None, + ): + super(RetryErrors, self).__init__(max_tries, delay, backoff, logger) + self.exception = exception + self.error_predicate = error_predicate + + def __call__(self, to_wrap): + @functools.wraps(to_wrap) + def wrapped_function(*args, **kwargs): + tries = 0 + while tries < self.max_tries: + try: + return to_wrap(*args, **kwargs) + except self.exception as caught_exception: + if not self.error_predicate(caught_exception): + raise + + delay = self.delay * self.backoff**tries + msg = "%s, Trying again in %d seconds..." % ( + caught_exception, + delay, + ) + self.logger(msg) + + time.sleep(delay) + tries += 1 + return to_wrap(*args, **kwargs) + + return wrapped_function + + +class RetryResult(RetryBase): + """Decorator for retrying based on non-error result. + + :type result_predicate: function, takes result, returns bool + :param result_predicate: Predicate evaluating whether to retry after a + result is returned. + + :type max_tries: int + :param max_tries: Number of times to try (not retry) before giving up. + + :type delay: int + :param delay: Initial delay between retries in seconds. + + :type backoff: int + :param backoff: Backoff multiplier e.g. value of 2 will double the + delay each retry. + + :type logger: logging.Logger instance + :param logger: Logger to use. If None, print. + """ + + def __init__( + self, + result_predicate, + max_tries=MAX_TRIES, + delay=DELAY, + backoff=BACKOFF, + logger=None, + ): + super(RetryResult, self).__init__(max_tries, delay, backoff, logger) + self.result_predicate = result_predicate + + def __call__(self, to_wrap): + @functools.wraps(to_wrap) + def wrapped_function(*args, **kwargs): + tries = 0 + while tries < self.max_tries: + result = to_wrap(*args, **kwargs) + if self.result_predicate(result): + return result + + delay = self.delay * self.backoff**tries + msg = "%s. Trying again in %d seconds..." % ( + self.result_predicate.__name__, + delay, + ) + self.logger(msg) + + time.sleep(delay) + tries += 1 + raise BackoffFailed() + + return wrapped_function + + +class RetryInstanceState(RetryBase): + """Decorator for retrying based on instance state. + + :type instance_predicate: function, takes instance, returns bool + :param instance_predicate: Predicate evaluating whether to retry after an + API-invoking method is called. + + :type max_tries: int + :param max_tries: Number of times to try (not retry) before giving up. + + :type delay: int + :param delay: Initial delay between retries in seconds. + + :type backoff: int + :param backoff: Backoff multiplier e.g. value of 2 will double the + delay each retry. + + :type logger: logging.Logger instance + :param logger: Logger to use. If None, print. + """ + + def __init__( + self, + instance_predicate, + max_tries=MAX_TRIES, + delay=DELAY, + backoff=BACKOFF, + logger=None, + ): + super(RetryInstanceState, self).__init__(max_tries, delay, backoff, logger) + self.instance_predicate = instance_predicate + + def __call__(self, to_wrap): + instance = to_wrap.__self__ # only instance methods allowed + + @functools.wraps(to_wrap) + def wrapped_function(*args, **kwargs): + tries = 0 + while tries < self.max_tries: + result = to_wrap(*args, **kwargs) + if self.instance_predicate(instance): + return result + + delay = self.delay * self.backoff**tries + msg = "%s. Trying again in %d seconds..." % ( + self.instance_predicate.__name__, + delay, + ) + self.logger(msg) + + time.sleep(delay) + tries += 1 + raise BackoffFailed() + + return wrapped_function diff --git a/packages/google-cloud-testutils/test_utils/system.py b/packages/google-cloud-testutils/test_utils/system.py new file mode 100644 index 000000000000..18a29303c449 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/system.py @@ -0,0 +1,80 @@ +# Copyright 2014 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 __future__ import print_function +import os +import sys +import time + +import google.auth.credentials # type: ignore +from google.auth.environment_vars import CREDENTIALS as TEST_CREDENTIALS # type: ignore + + +# From shell environ. May be None. +CREDENTIALS = os.getenv(TEST_CREDENTIALS) + +ENVIRON_ERROR_MSG = """\ +To run the system tests, you need to set some environment variables. +Please check the CONTRIBUTING guide for instructions. +""" + + +class EmulatorCreds(google.auth.credentials.Credentials): + """A mock credential object. + + Used to avoid unnecessary token refreshing or reliance on the network + while an emulator is running. + """ + + def __init__(self): # pylint: disable=super-init-not-called + self.token = b"seekrit" + self.expiry = None + + @property + def valid(self): + """Would-be validity check of the credentials. + + Always is :data:`True`. + """ + return True + + def refresh(self, unused_request): # pylint: disable=unused-argument + """Off-limits implementation for abstract method.""" + raise RuntimeError("Should never be refreshed.") + + +def check_environ(): + err_msg = None + if CREDENTIALS is None: + err_msg = "\nMissing variables: " + TEST_CREDENTIALS + elif not os.path.isfile(CREDENTIALS): + err_msg = "\nThe %s path %r is not a file." % (TEST_CREDENTIALS, CREDENTIALS) + + if err_msg is not None: + msg = ENVIRON_ERROR_MSG + err_msg + print(msg, file=sys.stderr) + sys.exit(1) + + +def unique_resource_id(delimiter="_"): + """A unique identifier for a resource. + + Intended to help locate resources created in particular + testing environments and at particular times. + """ + build_id = os.getenv("CIRCLE_BUILD_NUM", "") + if build_id == "": + return "%s%d" % (delimiter, 1000 * time.time()) + else: + return "%s%s%s%d" % (delimiter, build_id, delimiter, time.time()) diff --git a/packages/google-cloud-testutils/test_utils/version.py b/packages/google-cloud-testutils/test_utils/version.py new file mode 100644 index 000000000000..3ed5ae36dde2 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/version.py @@ -0,0 +1,15 @@ +# Copyright 2025 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__ = "1.7.0" diff --git a/packages/google-cloud-testutils/test_utils/vpcsc_config.py b/packages/google-cloud-testutils/test_utils/vpcsc_config.py new file mode 100644 index 000000000000..c5e36e767ce4 --- /dev/null +++ b/packages/google-cloud-testutils/test_utils/vpcsc_config.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019 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 +# +# https://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 os + +import pytest # type: ignore + + +INSIDE_VPCSC_ENVVAR = "GOOGLE_CLOUD_TESTS_IN_VPCSC" +PROJECT_INSIDE_ENVVAR = "PROJECT_ID" +PROJECT_OUTSIDE_ENVVAR = "GOOGLE_CLOUD_TESTS_VPCSC_OUTSIDE_PERIMETER_PROJECT" +BUCKET_OUTSIDE_ENVVAR = "GOOGLE_CLOUD_TESTS_VPCSC_OUTSIDE_PERIMETER_BUCKET" + + +class VPCSCTestConfig(object): + """System test utility for VPCSC detection. + + See: https://cloud.google.com/vpc-service-controls/docs/ + """ + + @property + def inside_vpcsc(self): + """Test whether the test environment is configured to run inside VPCSC. + + Returns: + bool: + true if the environment is configured to run inside VPCSC, + else false. + """ + return INSIDE_VPCSC_ENVVAR in os.environ + + @property + def project_inside(self): + """Project ID for testing outside access. + + Returns: + str: project ID used for testing outside access; None if undefined. + """ + return os.environ.get(PROJECT_INSIDE_ENVVAR, None) + + @property + def project_outside(self): + """Project ID for testing inside access. + + Returns: + str: project ID used for testing inside access; None if undefined. + """ + return os.environ.get(PROJECT_OUTSIDE_ENVVAR, None) + + @property + def bucket_outside(self): + """GCS bucket for testing inside access. + + Returns: + str: bucket ID used for testing inside access; None if undefined. + """ + return os.environ.get(BUCKET_OUTSIDE_ENVVAR, None) + + def skip_if_inside_vpcsc(self, testcase): + """Test decorator: skip if running inside VPCSC.""" + reason = ( + "Running inside VPCSC. " + "Unset the {} environment variable to enable this test." + ).format(INSIDE_VPCSC_ENVVAR) + skip = pytest.mark.skipif(self.inside_vpcsc, reason=reason) + return skip(testcase) + + def skip_unless_inside_vpcsc(self, testcase): + """Test decorator: skip if running outside VPCSC.""" + reason = ( + "Running outside VPCSC. " + "Set the {} environment variable to enable this test." + ).format(INSIDE_VPCSC_ENVVAR) + skip = pytest.mark.skipif(not self.inside_vpcsc, reason=reason) + return skip(testcase) + + def skip_unless_inside_project(self, testcase): + """Test decorator: skip if inside project env var not set.""" + reason = ( + "Project ID for running inside VPCSC not set. " + "Set the {} environment variable to enable this test." + ).format(PROJECT_INSIDE_ENVVAR) + skip = pytest.mark.skipif(self.project_inside is None, reason=reason) + return skip(testcase) + + def skip_unless_outside_project(self, testcase): + """Test decorator: skip if outside project env var not set.""" + reason = ( + "Project ID for running outside VPCSC not set. " + "Set the {} environment variable to enable this test." + ).format(PROJECT_OUTSIDE_ENVVAR) + skip = pytest.mark.skipif(self.project_outside is None, reason=reason) + return skip(testcase) + + def skip_unless_outside_bucket(self, testcase): + """Test decorator: skip if outside bucket env var not set.""" + reason = ( + "Bucket ID for running outside VPCSC not set. " + "Set the {} environment variable to enable this test." + ).format(BUCKET_OUTSIDE_ENVVAR) + skip = pytest.mark.skipif(self.bucket_outside is None, reason=reason) + return skip(testcase) + + +vpcsc_config = VPCSCTestConfig() diff --git a/packages/google-cloud-testutils/testing/.gitignore b/packages/google-cloud-testutils/testing/.gitignore new file mode 100644 index 000000000000..b05fbd630881 --- /dev/null +++ b/packages/google-cloud-testutils/testing/.gitignore @@ -0,0 +1,3 @@ +test-env.sh +service-account.json +client-secrets.json \ No newline at end of file diff --git a/packages/google-cloud-testutils/testing/constraints-3.10.txt b/packages/google-cloud-testutils/testing/constraints-3.10.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/testing/constraints-3.11.txt b/packages/google-cloud-testutils/testing/constraints-3.11.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/testing/constraints-3.12.txt b/packages/google-cloud-testutils/testing/constraints-3.12.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/testing/constraints-3.13.txt b/packages/google-cloud-testutils/testing/constraints-3.13.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/testing/constraints-3.14.txt b/packages/google-cloud-testutils/testing/constraints-3.14.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/testing/constraints-3.7.txt b/packages/google-cloud-testutils/testing/constraints-3.7.txt new file mode 100644 index 000000000000..e694520d0743 --- /dev/null +++ b/packages/google-cloud-testutils/testing/constraints-3.7.txt @@ -0,0 +1,4 @@ +click==7.0.0 +google-auth==0.4.0 +importlib_metadata==1.0.0 +packaging==19.0 diff --git a/packages/google-cloud-testutils/testing/constraints-3.8.txt b/packages/google-cloud-testutils/testing/constraints-3.8.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/testing/constraints-3.9.txt b/packages/google-cloud-testutils/testing/constraints-3.9.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/google-cloud-testutils/tests/unit/resources/bad_package/setup.py b/packages/google-cloud-testutils/tests/unit/resources/bad_package/setup.py new file mode 100644 index 000000000000..cff27e1a54de --- /dev/null +++ b/packages/google-cloud-testutils/tests/unit/resources/bad_package/setup.py @@ -0,0 +1,40 @@ +# Copyright 2021 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 setuptools + + +requirements = [ + "requests", # no lower bound + "packaging>=14.0, !=15.0", # too complex for tool + "wheel<0.45.0", + "click==7.0.0", +] + +setuptools.setup( + name="invalid-package", + version="0.0.1", + author="Example Author", + author_email="author@example.com", + description="A small example package", + long_description_content_type="text/markdown", + url="https://github.com/pypa/sampleproject", + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + ], + install_requires=requirements, + packages=setuptools.find_packages(), + python_requires=">=3.7", +) diff --git a/packages/google-cloud-testutils/tests/unit/resources/good_package/setup.py b/packages/google-cloud-testutils/tests/unit/resources/good_package/setup.py new file mode 100644 index 000000000000..2833216580ad --- /dev/null +++ b/packages/google-cloud-testutils/tests/unit/resources/good_package/setup.py @@ -0,0 +1,46 @@ +# Copyright 2021 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 setuptools + + +# This package has four requirements. +# Each uses a different kind of pin accepted by the function that +# extracts lower bounds. +requirements = [ + "requests>=1.0.0", + "packaging>=14.0", + "wheel >=0.41.0", + "click==7.0.0", +] + +extras = {"grpc": "grpcio>=1.0.0"} + +setuptools.setup( + name="valid-package", + version="0.0.1", + author="Example Author", + author_email="author@example.com", + description="A small example package", + long_description_content_type="text/markdown", + url="https://github.com/pypa/sampleproject", + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + ], + install_requires=requirements, + extras_require=extras, + packages=setuptools.find_packages(), + python_requires=">=3.7", +) diff --git a/packages/google-cloud-testutils/tests/unit/test_lower_bound_checker.py b/packages/google-cloud-testutils/tests/unit/test_lower_bound_checker.py new file mode 100644 index 000000000000..4cd467c9ec86 --- /dev/null +++ b/packages/google-cloud-testutils/tests/unit/test_lower_bound_checker.py @@ -0,0 +1,314 @@ +# Copyright 2021 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 contextlib import contextmanager +from pathlib import Path +import re +import tempfile +import sys +from typing import List + +from click.testing import CliRunner +import pytest # type: ignore + +if sys.version_info >= (3, 8): + import importlib.metadata as importlib_metadata +else: + # For Python 3.7 compatibility + import importlib_metadata + +from test_utils.lower_bound_checker import lower_bound_checker + +RUNNER = CliRunner() + +PACKAGE_LIST_REGEX = re.compile(r"Error.*[\[\{](.+)[\]\}]") +DIFFERENT_VERSIONS_LIST_REGEX = re.compile("'(.*?)' lower bound is") + +# These packages are installed into the environment by the nox session +# See 'resources/' for the setup.py files +GOOD_PACKAGE = "valid-package" +BAD_PACKAGE = "invalid-package" + + +def skip_test_if_not_installed(package_name: str): + """Skips the current test if given package is not installed""" + try: + importlib_metadata.distribution(package_name) + except importlib_metadata.PackageNotFoundError: + pytest.skip( + f"Skipping test which requires {package_name} in `tests/unit/resources/` to be installed" + ) + + +def parse_error_msg(msg: str) -> List[str]: + """Get package names from the error message. + + Example: + Error: setup.py is missing explicit lower bounds for the following packages: ["requests", "grpcio"] + """ + match = PACKAGE_LIST_REGEX.search(msg) + + reqs: List[str] = [] + + if match: + reqs = match.groups(1)[0].split(",") # type: ignore + reqs = [r.strip().replace("'", "").replace('"', "") for r in reqs] + + return reqs + + +def parse_diff_versions_error_msg(msg: str) -> List[str]: + """Get package names from the error message listing different versions + + Example: + 'requests' lower bound is 1.2.0 in setup.py but constraints file has 1.3.0 + 'grpcio' lower bound is 1.0.0 in setup.py but constraints file has 1.10.0 + """ + pattern = re.compile(DIFFERENT_VERSIONS_LIST_REGEX) + pkg_names = pattern.findall(msg) + + return pkg_names + + +@contextmanager +def constraints_file(requirements: List[str]): + """Write the list of requirements into a temporary file""" + + tmpdir = tempfile.TemporaryDirectory() + constraints_path = Path(tmpdir.name) / "constraints.txt" + + constraints_path.write_text("\n".join(requirements)) + yield constraints_path + + tmpdir.cleanup() + + +def test_update_constraints(): + skip_test_if_not_installed(GOOD_PACKAGE) + + with tempfile.TemporaryDirectory() as tmpdir: + constraints_path = Path(tmpdir) / "constraints.txt" + + result = RUNNER.invoke( + lower_bound_checker.update, + [ + "--package-name", + GOOD_PACKAGE, + "--constraints-file", + str(constraints_path), + ], + ) + + assert result.exit_code == 0 + assert constraints_path.exists() + + output = constraints_path.read_text().split("\n") + + assert output == [ + "click==7.0.0", + "grpcio==1.0.0", + "packaging==14.0", + "requests==1.0.0", + "wheel==0.41.0", + ] + + +def test_update_constraints_overwrites_existing_file(): + skip_test_if_not_installed(GOOD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==13.0", + "wheel==0.42.0", + "click==5.0.0", + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.update, + ["--package-name", GOOD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 0 + + output = c.read_text().split("\n") + assert output == [ + "click==7.0.0", + "grpcio==1.0.0", + "packaging==14.0", + "requests==1.0.0", + "wheel==0.41.0", + ] + + +def test_update_constraints_with_setup_py_missing_lower_bounds(): + skip_test_if_not_installed(BAD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==14.0", + "wheel==0.41.0", + "click==7.0.0", + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.update, + ["--package-name", BAD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 2 + assert "setup.py is missing explicit lower bounds" in result.output + + invalid_pkg_list = parse_error_msg(result.output) + assert set(invalid_pkg_list) == {"requests", "packaging", "wheel"} + + +def test_check(): + skip_test_if_not_installed(GOOD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==14.0", + "wheel==0.41.0", + "click==7.0.0", + "grpcio==1.0.0", + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.check, + ["--package-name", GOOD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 0 + + +def test_update_constraints_with_extra_constraints(): + skip_test_if_not_installed(GOOD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==14.0", + "wheel==0.41.0", + "click==7.0.0", + "grpcio==1.0.0", + "pytest==6.0.0", # additional requirement + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.check, + ["--package-name", GOOD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 0 + + +def test_check_with_missing_constraints_file(): + skip_test_if_not_installed(GOOD_PACKAGE) + + result = RUNNER.invoke( + lower_bound_checker.check, + [ + "--package-name", + GOOD_PACKAGE, + "--constraints-file", + "missing_constraints.txt", + ], + ) + + assert result.exit_code == 1 + assert isinstance(result.exception, FileNotFoundError) + + +def test_check_with_constraints_file_invalid_pins(): + skip_test_if_not_installed(GOOD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==14.0", + "wheel==1.0.0, <2.0.0dev", # should be == + "click>=7.0.0", # should be == + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.check, + ["--package-name", GOOD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 2 + + invalid_pkg_list = parse_error_msg(result.output) + + assert set(invalid_pkg_list) == {"wheel", "click"} + + +def test_check_with_constraints_file_missing_packages(): + skip_test_if_not_installed(GOOD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==14.0", + # missing 'wheel' and 'click' and extra 'grpcio' + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.check, + ["--package-name", GOOD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 2 + + invalid_pkg_list = parse_error_msg(result.output) + assert set(invalid_pkg_list) == {"wheel", "click", "grpcio"} + + +def test_check_with_constraints_file_different_versions(): + skip_test_if_not_installed(GOOD_PACKAGE) + + constraints = [ + "requests==1.2.0", # setup.py has 1.0.0 + "packaging==14.1", # setup.py has 14.0 + "wheel==0.42.0", # setup.py has 0.41.0 + "click==7.0.0", + "grpcio==1.0.0", + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.check, + ["--package-name", GOOD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 2 + + invalid_pkg_list = parse_diff_versions_error_msg(result.output) + assert set(invalid_pkg_list) == {"requests", "packaging", "wheel"} + + +def test_check_with_setup_py_missing_lower_bounds(): + skip_test_if_not_installed(BAD_PACKAGE) + + constraints = [ + "requests==1.0.0", + "packaging==14.0", + "wheel==1.0.0", + "click==7.0.0", + ] + with constraints_file(constraints) as c: + result = RUNNER.invoke( + lower_bound_checker.check, + ["--package-name", BAD_PACKAGE, "--constraints-file", c], + ) + + assert result.exit_code == 2 + + invalid_pkg_list = parse_error_msg(result.output) + assert set(invalid_pkg_list) == {"requests", "packaging", "wheel"} diff --git a/packages/google-cloud-testutils/tests/unit/test_orchestrate.py b/packages/google-cloud-testutils/tests/unit/test_orchestrate.py new file mode 100644 index 000000000000..c11ce4efaedf --- /dev/null +++ b/packages/google-cloud-testutils/tests/unit/test_orchestrate.py @@ -0,0 +1,380 @@ +# Copyright 2021 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 +# +# https://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 itertools +import threading + +try: + from unittest import mock +except ImportError: # pragma: NO PY3 COVER + import mock # type: ignore + +import pytest # type: ignore + +from test_utils import orchestrate + + +def test__permutations(): + sequence = [1, 2, 3, 1, 2, 3, 1, 2, 3] + permutations = orchestrate._permutations(sequence) + assert len(permutations) == 1680 + + result = list(permutations) + assert len(permutations) == len(result) # computed length matches reality + assert len(result) == len(set(result)) # no duplicates + assert result[0] == (1, 1, 1, 2, 2, 2, 3, 3, 3) + assert result[-1] == (3, 3, 3, 2, 2, 2, 1, 1, 1) + + assert list(orchestrate._permutations([1, 2, 3])) == [ + (1, 2, 3), + (1, 3, 2), + (2, 1, 3), + (2, 3, 1), + (3, 1, 2), + (3, 2, 1), + ] + + +class Test_orchestrate: + @staticmethod + def test_bad_keyword_argument(): + with pytest.raises(TypeError): + orchestrate.orchestrate(None, None, what="for?") + + @staticmethod + def test_no_failures(): + test_calls = [] + + def make_test(name): + def test(): # pragma: NO COVER + test_calls.append(name) # pragma: SYNCPOINT + test_calls.append(name) # pragma: SYNCPOINT + test_calls.append(name) + + return test + + test1 = make_test("A") + test2 = make_test("B") + + permutations = orchestrate._permutations(["A", "B", "A", "B", "A", "B"]) + expected = list(itertools.chain(*permutations)) + + counts = orchestrate.orchestrate(test1, test2) + assert counts == (3, 3) + assert test_calls == expected + + @staticmethod + def test_named_syncpoints(): + test_calls = [] + + def make_test(name): + def test(): # pragma: NO COVER + test_calls.append(name) # pragma: SYNCPOINT test_named_syncpoints + test_calls.append(name) # pragma: SYNCPOINT test_named_syncpoints + test_calls.append(name) # pragma: SYNCPOINT + + return test + + test1 = make_test("A") + test2 = make_test("B") + + permutations = orchestrate._permutations(["A", "B", "A", "B", "A", "B"]) + expected = list(itertools.chain(*permutations)) + + counts = orchestrate.orchestrate(test1, test2, name="test_named_syncpoints") + assert counts == (3, 3) + assert test_calls == expected + + @staticmethod + def test_syncpoints_decrease_after_initial_run(): + test_calls = [] + + def make_test(name): + syncpoints = [name] * 4 + + def test(): # pragma: NO COVER + test_calls.append(name) + if syncpoints: + syncpoints.pop() # pragma: SYNCPOINT + test_calls.append(name) + + return test + + test1 = make_test("A") + test2 = make_test("B") + + expected = [ + "A", + "A", + "B", + "B", + "A", + "B", + "A", + "B", + "A", + "B", + "B", + "A", + "B", + "A", + "A", + "B", + "B", + "A", + "B", + "A", + ] + + counts = orchestrate.orchestrate(test1, test2) + assert counts == (2, 2) + assert test_calls == expected + + @staticmethod + def test_syncpoints_increase_after_initial_run(): + test_calls = [] + + def do_nothing(): # pragma: NO COVER + pass + + def make_test(name): + syncpoints = [None] * 4 + + def test(): # pragma: NO COVER + test_calls.append(name) # pragma: SYNCPOINT + test_calls.append(name) + + if syncpoints: + syncpoints.pop() + else: + do_nothing() # pragma: SYNCPOINT + test_calls.append(name) + + return test + + test1 = make_test("A") + test2 = make_test("B") + + expected = [ + "A", + "A", + "B", + "B", + "A", + "B", + "A", + "B", + "A", + "B", + "B", + "A", + "B", + "A", + "A", + "B", + "B", + "A", + "B", + "A", + "A", + "B", + "B", + "B", + "A", + "A", + "A", + "B", + ] + + counts = orchestrate.orchestrate(test1, test2) + assert counts == (2, 2) + assert test_calls == expected + + @staticmethod + def test_failure(): + test_calls = [] + + def make_test(name): + syncpoints = [None] * 4 + + def test(): # pragma: NO COVER + test_calls.append(name) # pragma: SYNCPOINT + test_calls.append(name) + + if syncpoints: + syncpoints.pop() + else: + assert True is False + + return test + + test1 = make_test("A") + test2 = make_test("B") + + expected = [ + "A", + "A", + "B", + "B", + "A", + "B", + "A", + "B", + "A", + "B", + "B", + "A", + "B", + "A", + "A", + "B", + "B", + "A", + "B", + "A", + ] + + with pytest.raises(AssertionError): + orchestrate.orchestrate(test1, test2) + + assert test_calls == expected + + +def test__conductor(): + conductor = orchestrate._Conductor() + items = [] + + def run_in_test_thread(): + conductor.notify() + items.append("test1") + conductor.standby() + items.append("test2") + conductor.notify() + conductor.standby() + items.append("test3") + conductor.notify() + + assert not items + test_thread = threading.Thread(target=run_in_test_thread) + + test_thread.start() + conductor.wait() + assert items == ["test1"] + + conductor.go() + conductor.wait() + assert items == ["test1", "test2"] + + conductor.go() + conductor.wait() + assert items == ["test1", "test2", "test3"] + + +def test__get_syncpoints(): # pragma: SYNCPOINT test_get_syncpoints + with open(__file__, "r") as file: + lines = enumerate(file, start=1) + for expected_lineno, line in lines: # pragma: NO BRANCH COVER + if "# pragma: SYNCPOINT test_get_syncpoints" in line: + break + + orchestrate._get_syncpoints(__file__) + syncpoints = orchestrate._SYNCPOINTS[__file__]["test_get_syncpoints"] + assert syncpoints == {expected_lineno} + + +class Test_TestThread: + @staticmethod + def test__sync(): + test_thread = orchestrate._TestThread(None, None) + test_thread.conductor = mock.Mock() + test_thread._sync() + + test_thread.conductor.notify.assert_called_once_with() + test_thread.conductor.standby.assert_called_once_with() + + @staticmethod + def test__trace_no_source_file(): + orchestrate._SYNCPOINTS.clear() + frame = mock.Mock(f_globals={}, spec=("f_globals",)) + test_thread = orchestrate._TestThread(None, None) + assert test_thread._trace(frame, None, None) is None + assert not orchestrate._SYNCPOINTS + + @staticmethod + def test__trace_this_source_file(): + orchestrate._SYNCPOINTS.clear() + frame = mock.Mock( + f_globals={"__file__": __file__}, + f_lineno=1, + spec=( + "f_globals", + "f_lineno", + ), + ) + test_thread = orchestrate._TestThread(None, None) + assert test_thread._trace(frame, None, None) == test_thread._trace + assert __file__ in orchestrate._SYNCPOINTS + + @staticmethod + def test__trace_reach_syncpoint(): + with open(__file__, "r") as file: + lines = enumerate(file, start=1) + for syncpoint_lineno, line in lines: # pragma: NO BRANCH COVER + if "# pragma: SYNCPOINT test_get_syncpoints" in line: + break + + orchestrate._SYNCPOINTS.clear() + frame = mock.Mock( + f_globals={"__file__": __file__}, + f_lineno=syncpoint_lineno, + spec=( + "f_globals", + "f_lineno", + ), + ) + test_thread = orchestrate._TestThread(None, "test_get_syncpoints") + test_thread._sync = mock.Mock() + assert test_thread._trace(frame, None, None) == test_thread._trace + test_thread._sync.assert_not_called() + + frame = mock.Mock( + f_globals={"__file__": __file__}, + f_lineno=syncpoint_lineno + 1, + spec=( + "f_globals", + "f_lineno", + ), + ) + assert test_thread._trace(frame, None, None) == test_thread._trace + test_thread._sync.assert_called_once_with() + + @staticmethod + def test__trace_other_source_file_with_no_syncpoints(): + filename = orchestrate.__file__ + if filename.endswith(".pyc"): # pragma: NO COVER + filename = filename[:-1] + + orchestrate._SYNCPOINTS.clear() + frame = mock.Mock( + f_globals={"__file__": filename + "c"}, + f_lineno=1, + spec=( + "f_globals", + "f_lineno", + ), + ) + test_thread = orchestrate._TestThread(None, None) + assert test_thread._trace(frame, None, None) is None + syncpoints = orchestrate._SYNCPOINTS[filename] + assert not syncpoints diff --git a/packages/google-cloud-testutils/tests/unit/test_prefixer.py b/packages/google-cloud-testutils/tests/unit/test_prefixer.py new file mode 100644 index 000000000000..bfe2e8ee1b45 --- /dev/null +++ b/packages/google-cloud-testutils/tests/unit/test_prefixer.py @@ -0,0 +1,89 @@ +# Copyright 2021 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 datetime +import re + +import pytest # type: ignore + +import test_utils.prefixer + + +class FakeDateTime(object): + """Fake datetime class since pytest can't monkeypatch attributes of + built-in/extension type. + """ + + def __init__(self, fake_now): + self._fake_now = fake_now + + def now(self, timezone): + return self._fake_now + + strptime = datetime.datetime.strptime + + +@pytest.mark.parametrize( + ("repo", "relative_dir", "separator", "expected"), + [ + ( + "python-bigquery", + "samples/snippets", + "_", + "python_bigquery_samples_snippets", + ), + ("python-storage", "samples\\snippets", "-", "python-storage-samples-snippets"), + ], +) +def test_common_prefix(repo, relative_dir, separator, expected): + got = test_utils.prefixer._common_prefix(repo, relative_dir, separator=separator) + assert got == expected + + +def test_create_prefix(monkeypatch): + fake_datetime = FakeDateTime(datetime.datetime(2021, 6, 21, 3, 32, 0)) + monkeypatch.setattr(datetime, "datetime", fake_datetime) + + prefixer = test_utils.prefixer.Prefixer( + "python-test-utils", "tests/unit", separator="?" + ) + got = prefixer.create_prefix() + parts = got.split("?") + assert len(parts) == 7 + assert "?".join(parts[:5]) == "python?test?utils?tests?unit" + datetime_part = parts[5] + assert datetime_part == "20210621033200" + random_hex_part = parts[6] + assert re.fullmatch("[0-9a-f]+", random_hex_part) + + +@pytest.mark.parametrize( + ("resource_name", "separator", "expected"), + [ + ("test_utils_created_elsewhere", "_", False), + ("test_utils_20210620120000", "_", False), + ("test_utils_20210620120000_abcdef_my_name", "_", False), + ("test_utils_20210619120000", "_", True), + ("test_utils_20210619120000_abcdef_my_name", "_", True), + ("test?utils?created?elsewhere", "_", False), + ("test?utils?20210620120000", "?", False), + ("test?utils?20210619120000", "?", True), + ], +) +def test_should_cleanup(resource_name, separator, expected, monkeypatch): + fake_datetime = FakeDateTime(datetime.datetime(2021, 6, 21, 3, 32, 0)) + monkeypatch.setattr(datetime, "datetime", fake_datetime) + + prefixer = test_utils.prefixer.Prefixer("test", "utils", separator=separator) + assert prefixer.should_cleanup(resource_name) == expected