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