diff --git a/.flake8 b/.flake8 index a9c848e34..2d84a569e 100644 --- a/.flake8 +++ b/.flake8 @@ -2,3 +2,4 @@ ignore = E226,E302,E41,W503 max-line-length = 160 max-complexity = 10 +exclude = venv, .tox \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d15676975..ab7ac40ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### 1.4.3 + * 08f0dc0 - feat: added support for message provider using pact broker (#257) (Fabio Pulvirenti, Sun Sep 5 22:49:51 2021 +0200) +### 1.4.2 + * f2230b6 - chore: Bundle Ruby standalones into dist artifact. (#256) (Taj Pereira, Sun Aug 22 19:53:53 2021 +0930) + * e370786 - chore: Releasing version 1.4.1 (Elliott Murray, Tue Aug 17 18:55:53 2021 +0100) + * 7dc8864 - fix: make uvicorn versions over 0.14 (#255) (Elliott Murray, Tue Aug 17 18:51:52 2021 +0100) + * da49cd7 - chore: Releasing version 1.4.0 (Elliott Murray, Sat Aug 7 10:17:26 2021 +0100) + * 0089937 - fix: issue originating from snyk with requests and urllib (#252) (Elliott Murray, Sat Jul 31 12:46:15 2021 +0100) + * 903371b - feat: added support for message provider (#251) (Fabio Pulvirenti, Sat Jul 31 13:24:19 2021 +0200) + * 2c81029 - chore(snyk): update fastapi (#239) (Elliott Murray, Fri Jun 11 09:12:38 2021 +0100) +### 1.4.1 + * 7dc8864 - fix: make uvicorn versions over 0.14 (#255) (Elliott Murray, Tue Aug 17 18:51:52 2021 +0100) ### 1.4.0 * 0089937 - fix: issue originating from snyk with requests and urllib (#252) (Elliott Murray, Sat Jul 31 12:46:15 2021 +0100) * 903371b - feat: added support for message provider (#251) (Fabio Pulvirenti, Sat Jul 31 13:24:19 2021 +0200) diff --git a/MANIFEST.in b/MANIFEST.in index 3bbaceac4..160e1d2d4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ include LICENSE include *.txt include *.md +include pact/bin/* prune pact/test -prune pact/bin prune e2e diff --git a/Makefile b/Makefile index decc028c1..1e82f5f0d 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ define messaging cd examples/message pip install -r requirements.txt pip install -e ../../ - pytest + ./run_pytest.sh endef export messaging diff --git a/README.md b/README.md index 797cfcffe..c8dcd68e5 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,19 @@ Note: As of Version 1.0 deprecates support for python 2.7 to allow us to incorpo # How to use pact-python ## Installation + ``` pip install pact-python ``` ## Getting started + A guide follows but if you go to the [e2e examples](https://github.com/pact-foundation/pact-python/tree/master/examples/e2e). This has a consumer, provider and pact-broker set of tests. ## Writing a Pact -Creating a complete contract is a two step process: +Creating a complete contract is a two-step process: 1. Create a test on the consumer side that declares the expectations it has of the provider 2. Create a provider state that allows the contract to pass when replayed against the provider @@ -82,7 +84,6 @@ class GetUserInfoContract(unittest.TestCase): result = user('UserA') self.assertEqual(result, expected) - ``` This does a few important things: @@ -164,11 +165,13 @@ The mock service offers you several important features when building your contra - Finally, it will record your contracts as a JSON file that you can store in your repository or publish to a Pact broker. ## Expecting Variable Content + The above test works great if that user information is always static, but what happens if the user has a last updated field that is set to the current time every time the object is modified? To handle variable data and make your tests more robust, there are 3 helpful matchers: ### Term(matcher, generate) + Asserts the value should match the given regular expression. You could use this to expect a timestamp with a particular format in the request or response where you know you need a particular format, but are unconcerned about the exact date: @@ -194,6 +197,7 @@ provider, the regex will be used to search the response from the real provider s and the test will be considered successful if the regex finds a match in the response. ### Like(matcher) + Asserts the element's type matches the matcher. For example: ```python @@ -202,6 +206,7 @@ Like(123) # Matches if the value is an integer Like('hello world') # Matches if the value is a string Like(3.14) # Matches if the value is a float ``` + The argument supplied to `Like` will be what the mock service responds with. When a dictionary is used as an argument for Like, all the child objects (and their child objects etc.) will be matched according to their types, unless you use a more specific matcher like a Term. @@ -262,18 +267,18 @@ Format().ip_address # Matches if the value is a ip address We've created a number of them for you to save you the time: -| matcher | description | -|-----------------|-------------------------------------------------------------------------------------------------| -| `identifier` | Match an ID (e.g. 42) | -| `integer` | Match all numbers that are integers (both ints and longs) | -| `decimal` | Match all real numbers (floating point and decimal) | -| `hexadecimal` | Match all hexadecimal encoded strings | -| `date` | Match string containing basic ISO8601 dates (e.g. 2016-01-01) | -| `timestamp` | Match a string containing an RFC3339 formatted timestapm (e.g. Mon, 31 Oct 2016 15:21:41 -0400) | -| `time` | Match string containing times in ISO date format (e.g. T22:44:30.652Z) | -| `ip_address` | Match string containing IP4 formatted address | +| matcher | description | +|----------------|-------------------------------------------------------------------------------------------------| +| `identifier` | Match an ID (e.g. 42) | +| `integer` | Match all numbers that are integers (both ints and longs) | +| `decimal` | Match all real numbers (floating point and decimal) | +| `hexadecimal` | Match all hexadecimal encoded strings | +| `date` | Match string containing basic ISO8601 dates (e.g. 2016-01-01) | +| `timestamp` | Match a string containing an RFC3339 formatted timestamp (e.g. Mon, 31 Oct 2016 15:21:41 -0400) | +| `time` | Match string containing times in ISO date format (e.g. T22:44:30.652Z) | +| `ip_address` | Match string containing IP4 formatted address | | `ipv6_address` | Match string containing IP6 formatted address | -| `uuid` | Match strings containing UUIDs | +| `uuid` | Match strings containing UUIDs | These can be used to replace other matchers @@ -399,6 +404,7 @@ The provider application version. Required for publishing verification results. Publish verification results to the broker. ### Python API + You can use the Verifier class. This has all the same parameters as the cli tool but allows you to write native python code and the test framework of your choice. ```python @@ -406,11 +412,12 @@ verifier = Verifier(provider='UserService', provider_base_url=PACT_URL) output, logs = verifier.verify_pacts('./userserviceclient-userservice.json') - ``` + You can see more details in the [e2e examples](https://github.com/pact-foundation/pact-python/tree/master/examples/e2e/tests/provider/test_provider.py). ### Provider States + In many cases, your contracts will need very specific data to exist on the provider to pass successfully. If you are fetching a user profile, that user needs to exist, if querying a list of records, one or more records needs to exist. To support @@ -429,19 +436,20 @@ on the provider application or a separate one. Some strategies for managing stat For more information about provider states, refer to the [Pact documentation] on [Provider States]. # Development + Please read [CONTRIBUTING.md](https://github.com/pact-foundation/pact-python/blob/master/CONTRIBUTING.md) To setup a development environment: -1. If you want to run tests for all Python versions, install 2.7, 3.3, 3.4, 3.5, and 3.6 from source or using a tool like [pyenv] -2. Its recommended to create a Python [virtualenv] for the project. +1. If you want to run tests for all Python versions, install 3.6, 3.7, 3.8, and 3.9 from source or using a tool like [pyenv] +2. It's recommended to create a Python [virtualenv] for the project. 3. We are now using FFI bindings. For mac you might want to read these [setup FFI](https://cffi.readthedocs.io/en/latest/installation.html) -The setup the environment, run tests, and package the application, run: +To setup the environment, run tests, and package the application, run: `make release` -If you are just interested in packaging pact-python so you can install it using pip: +If you are just interested in packaging pact-python you can install it using pip: `make package` @@ -450,6 +458,15 @@ From there you can use pip to install it: `pip install ./dist/pact-python-N.N.N.tar.gz` +## Offline Installation of Standalone Packages + +Although all Ruby standalone applications are predownloaded into the wheel artifact, it may be useful, for development, purposes to install custom Ruby binaries. In which case, use the `bin-path` flag. +``` +pip install pact-python --bin-path=/absolute/path/to/folder/containing/pact/binaries/for/your/os +``` + +Pact binaries can be found at [Pact Ruby Releases](https://github.com/pact-foundation/pact-ruby-standalone/releases). + ## Testing This project has unit and end to end tests, which can both be run from make: diff --git a/examples/ffi/README.md b/examples/ffi/README.md new file mode 100644 index 000000000..cc2835238 --- /dev/null +++ b/examples/ffi/README.md @@ -0,0 +1,17 @@ +# FFI Examples + +This contains the following files which are for reference/information purposes +only i.e. they are not functionally used by end users. They may be used where +documented by developer helper scripts. The files are included here to make it +easier to identify when any changes have occurred in a new version of the Pact +FFI library. + +### pact_ffi_verifier_args.json + +The various arguments available to the Pact Verifier, both options and flags. +This format is used to construct the CLI arguments for `pact-verifier`, which +calls the method that produces this data each time during runtime. + +### pact_ffi_verifier_help.txt + +The output from ``pact-verifier --help`` \ No newline at end of file diff --git a/examples/ffi/pact_ffi_verifier_args.json b/examples/ffi/pact_ffi_verifier_args.json new file mode 100644 index 000000000..dde1fcc80 --- /dev/null +++ b/examples/ffi/pact_ffi_verifier_args.json @@ -0,0 +1,187 @@ +{ + "options": [ + { + "long": "loglevel", + "short": "l", + "help": "Log level (defaults to warn)", + "possible_values": [ + "error", + "warn", + "info", + "debug", + "trace", + "none" + ], + "multiple": false + }, + { + "long": "file", + "short": "f", + "help": "Pact file to verify (can be repeated)", + "multiple": true + }, + { + "long": "dir", + "short": "d", + "help": "Directory of pact files to verify (can be repeated)", + "multiple": true + }, + { + "long": "url", + "short": "u", + "help": "URL of pact file to verify (can be repeated)", + "multiple": true + }, + { + "long": "broker-url", + "short": "b", + "help": "URL of the pact broker to fetch pacts from to verify (requires the provider name parameter)", + "multiple": false, + "env": "PACT_BROKER_BASE_URL" + }, + { + "long": "hostname", + "short": "h", + "help": "Provider hostname (defaults to localhost)", + "multiple": false + }, + { + "long": "port", + "short": "p", + "help": "Provider port (defaults to protocol default 80/443)", + "multiple": false + }, + { + "long": "scheme", + "help": "Provider URI scheme (defaults to http)", + "possible_values": [ + "http", + "https" + ], + "default_value": "http", + "multiple": false + }, + { + "long": "provider-name", + "short": "n", + "help": "Provider name (defaults to provider)", + "multiple": false + }, + { + "long": "state-change-url", + "short": "s", + "help": "URL to post state change requests to", + "multiple": false + }, + { + "long": "filter-description", + "help": "Only validate interactions whose descriptions match this filter", + "multiple": false, + "env": "PACT_DESCRIPTION" + }, + { + "long": "filter-state", + "help": "Only validate interactions whose provider states match this filter", + "multiple": false, + "env": "PACT_PROVIDER_STATE" + }, + { + "long": "filter-no-state", + "help": "Only validate interactions that have no defined provider state", + "multiple": false, + "env": "PACT_PROVIDER_NO_STATE" + }, + { + "long": "filter-consumer", + "short": "c", + "help": "Consumer name to filter the pacts to be verified (can be repeated)", + "multiple": true + }, + { + "long": "user", + "help": "Username to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_USERNAME" + }, + { + "long": "password", + "help": "Password to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_PASSWORD" + }, + { + "long": "token", + "short": "t", + "help": "Bearer token to use when fetching pacts from URLS", + "multiple": false, + "env": "PACT_BROKER_TOKEN" + }, + { + "long": "provider-version", + "help": "Provider version that is being verified. This is required when publishing results.", + "multiple": false + }, + { + "long": "build-url", + "help": "URL of the build to associate with the published verification results.", + "multiple": false + }, + { + "long": "provider-tags", + "help": "Provider tags to use when publishing results. Accepts comma-separated values.", + "multiple": false + }, + { + "long": "base-path", + "help": "Base path to add to all requests", + "multiple": false + }, + { + "long": "consumer-version-tags", + "help": "Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values.", + "multiple": false + }, + { + "long": "consumer-version-selectors", + "help": "Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/", + "multiple": false + }, + { + "long": "include-wip-pacts-since", + "help": "Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip", + "multiple": false + }, + { + "long": "request-timeout", + "help": "Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests.", + "multiple": false + } + ], + "flags": [ + { + "long": "state-change-as-query", + "help": "State change request data will be sent as query parameters instead of in the request body", + "multiple": false + }, + { + "long": "state-change-teardown", + "help": "State change teardown requests are to be made after each interaction", + "multiple": false + }, + { + "long": "publish", + "help": "Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters.", + "multiple": false + }, + { + "long": "disable-ssl-verification", + "help": "Disables validation of SSL certificates", + "multiple": false + }, + { + "long": "enable-pending", + "help": "Enables Pending Pacts", + "multiple": false + } + ] +} \ No newline at end of file diff --git a/examples/ffi/pact_ffi_verifier_help.txt b/examples/ffi/pact_ffi_verifier_help.txt new file mode 100644 index 000000000..2c354c663 --- /dev/null +++ b/examples/ffi/pact_ffi_verifier_help.txt @@ -0,0 +1,37 @@ +Usage: pact-verifier [OPTIONS] + +Options: + --debug-click Display arguments passed to the FFI library + --enable-pending Enables Pending Pacts + --disable-ssl-verification Disables validation of SSL certificates + --publish Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters. + --state-change-teardown State change teardown requests are to be made after each interaction + --state-change-as-query State change request data will be sent as query parameters instead of in the request body + --request-timeout TEXT Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests. + --include-wip-pacts-since TEXT Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip + --consumer-version-selectors TEXT + Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/ + --consumer-version-tags TEXT Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values. + --base-path TEXT Base path to add to all requests + --provider-tags TEXT Provider tags to use when publishing results. Accepts comma-separated values. + --build-url TEXT URL of the build to associate with the published verification results. + --provider-version TEXT Provider version that is being verified. This is required when publishing results. + -t, --token TEXT Bearer token to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_TOKEN + --password TEXT Password to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_PASSWORD + --user TEXT Username to use when fetching pacts from URLS. Alternatively: $PACT_BROKER_USERNAME + -c, --filter-consumer TEXT Consumer name to filter the pacts to be verified (can be repeated) + --filter-no-state TEXT Only validate interactions that have no defined provider state. Alternatively: $PACT_PROVIDER_NO_STATE + --filter-state TEXT Only validate interactions whose provider states match this filter. Alternatively: $PACT_PROVIDER_STATE + --filter-description TEXT Only validate interactions whose descriptions match this filter. Alternatively: $PACT_DESCRIPTION + -s, --state-change-url TEXT URL to post state change requests to + -n, --provider-name TEXT Provider name (defaults to provider) + --scheme [http|https] Provider URI scheme (defaults to http) + -p, --port TEXT Provider port (defaults to protocol default 80/443) + -h, --hostname TEXT Provider hostname (defaults to localhost) + -b, --broker-url TEXT URL of the pact broker to fetch pacts from to verify (requires the provider name parameter). Alternatively: $PACT_BROKER_BASE_URL + -u, --url TEXT URL of pact file to verify (can be repeated) + -d, --dir TEXT Directory of pact files to verify (can be repeated) + -f, --file TEXT Pact file to verify (can be repeated) + -l, --loglevel [error|warn|info|debug|trace|none] + Log level (defaults to warn) + --help Show this message and exit. \ No newline at end of file diff --git a/examples/message/README.md b/examples/message/README.md index 55081fa39..13c738057 100644 --- a/examples/message/README.md +++ b/examples/message/README.md @@ -99,9 +99,6 @@ def test_throw_exception_handler(pact): ## Provider -Note: The current example only tests the consumer side. -In the future, provider tests will also be included. - ``` +-------------------+ +-----------+ |(Message Provider) | message | (Pact) | @@ -110,10 +107,82 @@ In the future, provider tests will also be included. +-------------------+ +-----------+ ``` -## E2E Messaging +```python +import pytest +from pact import MessageProvider + +def document_created_handler(): + return { + "event": "ObjectCreated:Put", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" + } + + +def test_verify_success(): + provider = MessageProvider( + message_providers={ + 'A document created successfully': document_created_handler + }, + provider='ContentProvider', + consumer='DetectContentLambda', + pact_dir='pacts' + + ) + with provider: + provider.verify() +``` + + +### Provider with pact broker +```python +import pytest +from pact import MessageProvider + + +PACT_BROKER_URL = "http://localhost" +PACT_BROKER_USERNAME = "pactbroker" +PACT_BROKER_PASSWORD = "pactbroker" +PACT_DIR = "pacts" + + +@pytest.fixture +def default_opts(): + return { + 'broker_username': PACT_BROKER_USERNAME, + 'broker_password': PACT_BROKER_PASSWORD, + 'broker_url': PACT_BROKER_URL, + 'publish_version': '3', + 'publish_verification_results': False + } + +def document_created_handler(): + return { + "event": "ObjectCreated:Put", + "documentName": "document.doc", + "creator": "TP", + "documentType": "microsoft-word" + } -Note: The current example only tests the consumer side. -In the future, provider tests will also be included. +def test_verify_from_broker(default_opts): + provider = MessageProvider( + message_providers={ + 'A document created successfully': document_created_handler, + }, + provider='ContentProvider', + consumer='DetectContentLambda', + pact_dir='pacts' + + ) + + with pytest.raises(AssertionError): + with provider: + provider.verify_with_broker(**default_opts) + +``` + +## E2E Messaging ``` +-------------------+ +-----------+ +-------------------+ diff --git a/examples/message/conftest.py b/examples/message/conftest.py new file mode 100644 index 000000000..1ee4c7dc2 --- /dev/null +++ b/examples/message/conftest.py @@ -0,0 +1,52 @@ + +from testcontainers.compose import DockerCompose + +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--publish-pact", type=str, action="store", + help="Upload generated pact file to pact broker with version" + ) + + parser.addoption( + "--provider-url", type=str, action="store", + help="The url to our provider." + ) + + parser.addoption( + "--run-broker", type=bool, action="store", + help="Whether to run broker in this test or not." + ) + + +# This fixture is to simulate a managed Pact Broker or Pactflow account +# Do not do this yourself but setup one of the above +# https://github.com/pact-foundation/pact_broker +@pytest.fixture(scope='session', autouse=True) +def broker(request): + version = request.config.getoption('--publish-pact') + publish = True if version else False + + # yield + if not publish: + yield + return + + run_broker = request.config.getoption('--run-broker') + + if not run_broker: + yield + return + else: + print('Starting broker') + with DockerCompose("../broker", + compose_file_name=["docker-compose.yml"], + pull=True) as compose: + + stdout, stderr = compose.get_logs() + if stderr: + print("Errors\\n:{}".format(stderr)) + print(stdout) + yield diff --git a/examples/message/run_pytest.sh b/examples/message/run_pytest.sh index 15c3b1fdb..35dade4fb 100755 --- a/examples/message/run_pytest.sh +++ b/examples/message/run_pytest.sh @@ -1,7 +1,7 @@ #!/bin/bash set -o pipefail -pytest +pytest --run-broker True --publish-pact 2 # publish to broker assuming broker is active # pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 diff --git a/examples/message/tests/conftest.py b/examples/message/tests/conftest.py deleted file mode 100644 index 7873fddf4..000000000 --- a/examples/message/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ - -def pytest_addoption(parser): - parser.addoption( - "--publish-pact", type=str, action="store", - help="Upload generated pact file to pact broker with version" - ) diff --git a/examples/message/tests/consumer/test_message_consumer.py b/examples/message/tests/consumer/test_message_consumer.py index b5671da66..d8376a4e6 100644 --- a/examples/message/tests/consumer/test_message_consumer.py +++ b/examples/message/tests/consumer/test_message_consumer.py @@ -123,6 +123,8 @@ def test_publish_to_broker(pact): `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker --publish-pact 2` """ + cleanup_json(PACT_FILE) + expected_event = { "event": "ObjectCreated:Delete", "documentName": "document.doc", diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py index f7eab414d..ae20ab3a1 100644 --- a/examples/message/tests/provider/test_message_provider.py +++ b/examples/message/tests/provider/test_message_provider.py @@ -1,6 +1,22 @@ import pytest from pact import MessageProvider +PACT_BROKER_URL = "http://localhost" +PACT_BROKER_USERNAME = "pactbroker" +PACT_BROKER_PASSWORD = "pactbroker" +PACT_DIR = "pacts" + + +@pytest.fixture +def default_opts(): + return { + 'broker_username': PACT_BROKER_USERNAME, + 'broker_password': PACT_BROKER_PASSWORD, + 'broker_url': PACT_BROKER_URL, + 'publish_version': '3', + 'publish_verification_results': False + } + def document_created_handler(): return { @@ -49,3 +65,19 @@ def test_verify_failure_when_a_provider_missing(): with pytest.raises(AssertionError): with provider: provider.verify() + + +def test_verify_from_broker(default_opts): + provider = MessageProvider( + message_providers={ + 'A document created successfully': document_created_handler, + 'A document deleted successfully': document_deleted_handler + }, + provider='ContentProvider', + consumer='DetectContentLambda', + pact_dir='pacts' + + ) + + with provider: + provider.verify_with_broker(**default_opts) diff --git a/examples/pacts/pact-consumer-one-pact-provider-one.json b/examples/pacts/pact-consumer-one-pact-provider-one.json new file mode 100644 index 000000000..79c031487 --- /dev/null +++ b/examples/pacts/pact-consumer-one-pact-provider-one.json @@ -0,0 +1,32 @@ +{ + "consumer": { + "name": "pact-consumer-one" + }, + "provider": { + "name": "pact-provider-one" + }, + "interactions": [ + { + "description": "Data is requested from provider-one", + "providerState": "Some data exists to be returned by provider-one endpoint", + "request": { + "method": "get", + "path": "/test-provider-one" + }, + "response": { + "status": 200, + "headers": { + "Content-type": "application/json" + }, + "body": { + "answer": 42 + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/pact/__version__.py b/pact/__version__.py index 3380d93bb..30af9c36f 100644 --- a/pact/__version__.py +++ b/pact/__version__.py @@ -1,3 +1,3 @@ """Pact version info.""" -__version__ = '1.4.0' +__version__ = '1.4.3' diff --git a/pact/ffi/cli/verify.py b/pact/ffi/cli/verify.py new file mode 100644 index 000000000..dff07cc5a --- /dev/null +++ b/pact/ffi/cli/verify.py @@ -0,0 +1,127 @@ +"""Methods to verify previously created pacts.""" +import re +import sys +from typing import Callable + +import click +from click.core import ParameterSource + +from pact.ffi.verifier import Verifier, Arguments + + +def cli_options(): + """ + Dynamically construct the Click CLI options available to interface with the + current version of the FFI library. + This attempts to ensure there cannot be a mismatch between the two, and + means there doesn't need to be a duplication of logic. + """ + + def inner_func(function: Callable) -> Callable: + verifier = Verifier() + args: Arguments = verifier.cli_args() + + # Handle the options requiring values + for opt in args.options: + type_choice = click.Choice(opt.possible_values) if opt.possible_values else None + + # Let the user know if an ENV can be used here instead + _help = f"{opt.help}{f'. Alternatively: ${click.style(opt.env, bold=True)}' if opt.env else ''}" + + if opt.short: + function = click.option( + f"-{opt.short}", + f"--{opt.long}", + help=_help, + type=type_choice, + default=opt.default_value, + multiple=opt.multiple, + envvar=opt.env, + )(function) + else: + function = click.option( + f"--{opt.long}", + help=_help, + type=type_choice, + default=opt.default_value, + multiple=opt.multiple, + envvar=opt.env, + )(function) + + # Handle the boolean flags + for flag in args.flags: + # Let the user know if an ENV can be used here instead + # Note: future proofing, there do not seem to be any as of Pact FFI Library 0.0.2 + _help = f"{flag.help}{f'. Alternatively: ${click.style(flag.env, bold=True)}' if flag.env else ''}" + + function = click.option(f"--{flag.long}", help=_help, envvar=flag.env, is_flag=True)(function) + + function = click.option( + f"--debug-click", + help="Display arguments passed to the Pact Rust FFI library, for debugging pact-verifier wrapper", + is_flag=True, + )(function) + + return function + + return inner_func + + +@click.command(name="pact-verifier", context_settings=dict(max_content_width=120)) +@cli_options() +def main(**kwargs): + """ + Verify one or more contracts against a provider service. + + Minimal example: pact-verifier --hostname localhost --port 8080 -d ./pacts + """ + # Since we may only have default args, which are SOME args and we don't know + # which are required, make sure we have at least one CLI argument + ctx = click.get_current_context() + if not [key for key, value in kwargs.items() if ctx.get_parameter_source(key) != ParameterSource.DEFAULT]: + click.echo(ctx.get_help()) + sys.exit(0) + + verifier = Verifier() + + cli_args = verifier.args_dict_to_str(kwargs) + + if kwargs.get("debug_click"): + click.echo("kwargs received:") + click.echo(kwargs) + click.echo("") + + # To try and avoid confusion and help with debugging, notify the user when ENVs are being used + arguments_from_envs = [ + key for key, value in kwargs.items() if ctx.get_parameter_source(key) == ParameterSource.ENVIRONMENT + ] + if arguments_from_envs: + click.echo(f"The following arguments are using values provided by ENVs: {arguments_from_envs}") + click.echo("") + + click.echo("CLI args to send via FFI:") + click.echo(cli_args) + click.echo("") + + result = verifier.verify(cli_args) + + if kwargs.get("debug_click"): + click.echo("Result from FFI call to verify:") + click.echo(f"{result.return_code=}") + click.echo(f"{result.logs=}") + + # If the FFI method returned some log output + if result.logs: + for log in result.logs: + m = re.search('.*error verifying Pact: "error: (.*)", kind: .*', log) + if m: + for line in m.group(1).split("\\n"): + click.echo(line) + else: + click.echo(log) + + sys.exit(result.return_code) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pact/ffi/log.py b/pact/ffi/log.py new file mode 100644 index 000000000..fcccdb21d --- /dev/null +++ b/pact/ffi/log.py @@ -0,0 +1,32 @@ +"""For handling the logging setup and output from the FFI library +As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/log/index.html +""" + +from enum import unique, Enum + + +@unique +class LogToBufferStatus(Enum): + """Return codes from a request to setup a logger. + + As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/log/fn.pactffi_logger_attach_sink.html#error-handling + """ + + SUCCESS = 0 # Operation succeeded + CANT_SET_LOGGER = -1 # Can't set the logger + NO_LOGGER = -2 # No logger has been initialized + SPECIFIER_NOT_UTF8 = -3 # The sink specifier was not UTF-8 encoded + UNKNOWN_SINK_TYPE = -4 # The sink type specified is not a known type + MISSING_FILE_PATH = -5 # No file path was specified in the sink specification + CANT_OPEN_SINK_TO_FILE = -6 # Opening a sink to the given file failed + CANT_CONSTRUCT_SINK = -7 # Can't construct sink + + +@unique +class LogLevel(Enum): + OFF = 0 + ERROR = 1 + WARN = 2 + INFO = 3 + DEBUG = 4 + TRACE = 5 diff --git a/pact/ffi/pact_ffi.py b/pact/ffi/pact_ffi.py new file mode 100644 index 000000000..bd1dc06ce --- /dev/null +++ b/pact/ffi/pact_ffi.py @@ -0,0 +1,139 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +import os +import platform +import tempfile +from typing import List + +from cffi import FFI +import threading + +from pact.ffi.log import LogToBufferStatus, LogLevel + + +class PactFFI(object): + """This interfaces with the Rust crate `pact_ffi`_, which exposes the Pact + API via a C Foreign Function Interface. In the case of python, the library + is then accessed using `CFFI`_. + + This class will implement the shared library loading, along with a wrapper + for the functions provided by the base crate. For each of the Rust modules + exposed, a corresponding python class will extend this base class, and + provide the wrapper for the functions the module provides. + + .. _pact_ffi: + https://docs.rs/pact_ffi/0.0.1/pact_ffi/index.html + .. _CFFI: + https://cffi.readthedocs.io/en/latest/ + """ + + ffi: FFI = None + lib = None + + _instance = None + _lock = threading.Lock() + + # Required if outputting logs to a file, can be remove if using a buffer + output_dir: tempfile.TemporaryDirectory = None + output_file: str = None + + def __new__(cls): + # Make sure we only initialise once, or the log setup will fail + if not cls._instance: + with cls._lock: + if not cls._instance: + cls._instance = super(PactFFI, cls).__new__(cls) + cls.ffi = FFI() + + # Define all the functions from the various modules, since we + # can only load the library once + cls.ffi.cdef( + """ + // root crate + char *pactffi_version(void); + + // verifier + int pactffi_verify(char *); + + // mock_server + void pactffi_free_string(char *); + + // log + int pactffi_log_to_file(char *, int); + int pactffi_log_to_buffer(int); + char * pactffi_fetch_log_buffer(void); + + // experimenting + char *pactffi_verifier_cli_args(void); + """ + ) + cls.lib = cls._load_ffi_library(cls.ffi) + + # We can setup logs like this, if preferred to buffer: + # The output will be stored in a file in this directory, which + # will be cleaned up automatically at the end + PactFFI.output_dir = tempfile.TemporaryDirectory() + # Setup logging to a file in the output_dir + PactFFI.output_file = os.path.join(PactFFI.output_dir.name, "output") + output_c = cls.ffi.new("char[]", bytes(cls.output_file, "utf-8")) + result = cls.lib.pactffi_log_to_file(output_c, LogLevel.INFO.value) + assert LogToBufferStatus(result) == LogToBufferStatus.SUCCESS + + # Having problems with the buffer output, when running via CLI + # Reverting to log file output instead + # result = cls.lib.pactffi_log_to_buffer(LogLevel.INFO.value) + # assert LogToBufferStatus(result) == LogToBufferStatus.SUCCESS + return cls._instance + + def version(self) -> str: + """Get the current library version. + + :return: pact_ffi library version, for example "0.0.1" + """ + result = self.lib.pactffi_version() + return self.ffi.string(result).decode("utf-8") + + @staticmethod + def _load_ffi_library(ffi): + """Load the appropriate library for the current platform.""" + target_platform = platform.platform().lower() + + if ("darwin" in target_platform or "macos" in target_platform) and "aarch64" in platform.machine(): + # TODO: Untested, can someone with the appropriate architecture verify? + libname = "libpact_ffi-osx-aarch64-apple-darwin.dylib" + elif target_platform in ["darwin", "macos"]: + libname = "pact/bin/libpact_ffi-osx-x86_64.dylib" + elif "linux" in target_platform: + libname = "pact/bin/libpact_ffi-linux-x86_64.so" + elif "windows" in target_platform: + libname = "pact/bin/pact_ffi-windows-x86_64.dll" + else: + msg = ( + f"Unfortunately, {platform.platform()} is not a supported " + f"platform. Only Linux, Windows, and OSX are currently " + f"supported." + ) + raise Exception(msg) + + # If a custom libpact_ffi.so is available in the pact/bin dir, use that instead + custom_libpact_ffi = os.path.join("pact/bin", "libpact_ffi.so") + if os.path.isfile(custom_libpact_ffi): + libname = custom_libpact_ffi + + return ffi.dlopen(libname) + + def get_logs(self) -> List[str]: + """Wrapper to retrieve the contents of the FFI log buffer. + + :return: List of log entries, each a line of log output + """ + + # Having problems with the buffer output, when running via CLI + # Reverting to log file output instead + # result = self.lib.pactffi_fetch_log_buffer() + # print(f"{result=}") + # return self.ffi.string(result).decode("utf-8").rstrip().split("\n") + + # If using log to file, retrieve like this: + lines = open(PactFFI.output_file).readlines() + open(PactFFI.output_file, "w").close() + return [line.lstrip("\x00") for line in lines] diff --git a/pact/ffi/verifier.py b/pact/ffi/verifier.py new file mode 100644 index 000000000..93e2afe07 --- /dev/null +++ b/pact/ffi/verifier.py @@ -0,0 +1,135 @@ +"""Wrapper to pact reference dynamic libraries using FFI.""" +from enum import Enum, unique +from typing import Dict, NamedTuple, List + +from pact.ffi.pact_ffi import PactFFI +import json + + +@unique +class VerifyStatus(Enum): + """Return codes from a verify request. + + As per: https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/fn.pactffi_verify.html + """ + + SUCCESS = 0 # Operation succeeded + VERIFIER_FAILED = 1 # The verification process failed, see output for errors + NULL_POINTER = 2 # A null pointer was received + PANIC = 3 # The method panicked + INVALID_ARGS = 4 # Invalid arguments were provided to the verification process + + +class VerifyResult(NamedTuple): + """Wrap up the return code, and log output.""" + + return_code: VerifyStatus + logs: List[str] + + +class Argument: + """Hold the attributes of a single argument which can be used by the Verifier.""" + + long: str # For example: "token" + short: str = None # For example "t" + help: str # Help description, for example: "Bearer token to use when fetching pacts from URLS" + default_value: str = None # The value which will be passed if none are provided, such as "http" for schema + possible_values: List[str] = None # If only specific values can be used, such as ["http", "https"] for schema + multiple: bool # If the argument can be provided multiple times, for example with file + env: str = None # ENV which will be used in the absence of a provided argument, for example PACT_BROKER_TOKEN + + def __init__( + self, + long: str, + help: str, + multiple: bool, + short: str = None, + default_value: str = None, + possible_values: List[str] = None, + env: str = None, + ): + self.long = long + self.short = short + self.help = help + self.default_value = default_value + self.possible_values = possible_values + self.multiple = multiple + self.env = env + + +class Arguments: + """Hold the various options and flags which can be used by the Verifier.""" + + options: List[Argument] = [] + flags: List[Argument] = [] + + def __init__(self, options: List[Argument], flags: List[Argument]): + self.options = [Argument(**option) for option in options] + self.flags = [Argument(**flags) for flags in flags] + + +class Verifier(PactFFI): + """A Pact Verifier Wrapper. + + This interfaces with the Rust FFI crate pact_ffi, specifically the + `verifier`_ module. + + .. _verifier: + https://docs.rs/pact_ffi/0.0.2/pact_ffi/verifier/index.html + """ + + def __new__(cls): + return super(Verifier, cls).__new__(cls) + + def verify(self, args=None) -> VerifyResult: + """Call verify method.""" + + # The FFI library specifically defines "usage" of no args, so we will + # replicate that here. In reality we will always want args. + if args: + c_args = self.ffi.new("char[]", bytes(args, "utf-8")) + else: + c_args = self.ffi.NULL + + result = self.lib.pactffi_verify(c_args) + logs = self.get_logs() + return VerifyResult(result, logs) + + def _cli_args_raw(self) -> Dict: + """Call and return the output from the pactffi_verifier_cli_args method. + + :return: The arguments, in raw dict form + """ + + result = self.lib.pactffi_verifier_cli_args() + arguments = json.loads(self.ffi.string(result).decode("utf-8")) + self.lib.pactffi_free_string(result) + return arguments + + def cli_args(self) -> Arguments: + """Retrieve the Arguments available to the Pact Verifier. + + :return: The arguments, in a Arguments structure + """ + arguments = Arguments(**self._cli_args_raw()) + return arguments + + @staticmethod + def args_dict_to_str(cli_args_dict: Dict) -> str: + """Convert a dict of arguments to the \n delimited str required to call the FFI function.""" + cli_args = "" + for key, value in cli_args_dict.items(): + # Special case, don't pass through the debug flag for Click + if key == "debug_click": + continue + + key_arg = key.replace("_", "-") # Snake case for python, kebab case for CLI + if value and isinstance(value, bool): + cli_args = f"{cli_args}\n--{key_arg}" + elif value and isinstance(value, str): + cli_args = f"{cli_args}\n--{key_arg}={value}" + elif value and isinstance(value, tuple) or isinstance(value, list): + for multiple_opt in value: + cli_args = f"{cli_args}\n--{key_arg}={multiple_opt}" + cli_args = cli_args.strip() + return cli_args diff --git a/pact/ffi/verifier_args.py b/pact/ffi/verifier_args.py new file mode 100644 index 000000000..3884b6403 --- /dev/null +++ b/pact/ffi/verifier_args.py @@ -0,0 +1,97 @@ +import typing +from dataclasses import dataclass, field + + +@dataclass +class VerifierArgs: + """Auto-generated class, containing the arguments available to the pact verifier.""" + + # Log level (defaults to warn) + loglevel: typing.Optional[str] = None + + # URL of the pact broker to fetch pacts from to verify (requires the provider name parameter) + broker_url: typing.Optional[str] = None + + # Provider hostname (defaults to localhost) + hostname: typing.Optional[str] = None + + # Provider port (defaults to protocol default 80/443) + port: typing.Optional[str] = None + + # Provider URI scheme (defaults to http) + scheme: typing.Optional[str] = None + + # Provider name (defaults to provider) + provider_name: typing.Optional[str] = None + + # URL to post state change requests to + state_change_url: typing.Optional[str] = None + + # Only validate interactions whose descriptions match this filter + filter_description: typing.Optional[str] = None + + # Only validate interactions whose provider states match this filter + filter_state: typing.Optional[str] = None + + # Only validate interactions that have no defined provider state + filter_no_state: typing.Optional[str] = None + + # Username to use when fetching pacts from URLS + user: typing.Optional[str] = None + + # Password to use when fetching pacts from URLS + password: typing.Optional[str] = None + + # Bearer token to use when fetching pacts from URLS + token: typing.Optional[str] = None + + # Provider version that is being verified. This is required when publishing results. + provider_version: typing.Optional[str] = None + + # URL of the build to associate with the published verification results. + build_url: typing.Optional[str] = None + + # Provider tags to use when publishing results. Accepts comma-separated values. + provider_tags: typing.Optional[str] = None + + # Base path to add to all requests + base_path: typing.Optional[str] = None + + # Consumer tags to use when fetching pacts from the Broker. Accepts comma-separated values. + consumer_version_tags: typing.Optional[str] = None + + # Consumer version selectors to use when fetching pacts from the Broker. Accepts a JSON string as per https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/ + consumer_version_selectors: typing.Optional[str] = None + + # Allow pacts that don't match given consumer selectors (or tags) to be verified, without causing the overall task to fail. For more information, see https://pact.io/wip + include_wip_pacts_since: typing.Optional[str] = None + + # Sets the HTTP request timeout in milliseconds for requests to the target API and for state change requests. + request_timeout: typing.Optional[str] = None + + # Pact file to verify (can be repeated) + file: typing.Optional[typing.List[str]] = field(default_factory=list) + + # Directory of pact files to verify (can be repeated) + dir: typing.Optional[typing.List[str]] = field(default_factory=list) + + # URL of pact file to verify (can be repeated) + url: typing.Optional[typing.List[str]] = field(default_factory=list) + + # Consumer name to filter the pacts to be verified (can be repeated) + filter_consumer: typing.Optional[typing.List[str]] = field(default_factory=list) + + # State change request data will be sent as query parameters instead of in the request body + state_change_as_query: typing.Optional[bool] = None + + # State change teardown requests are to be made after each interaction + state_change_teardown: typing.Optional[bool] = None + + # Enables publishing of verification results back to the Pact Broker. Requires the broker-url and provider-version parameters. + publish: typing.Optional[bool] = None + + # Disables validation of SSL certificates + disable_ssl_verification: typing.Optional[bool] = None + + # Enables Pending Pacts + enable_pending: typing.Optional[bool] = None diff --git a/pact/ffi/verify_wrapper.py b/pact/ffi/verify_wrapper.py new file mode 100644 index 000000000..5d9847665 --- /dev/null +++ b/pact/ffi/verify_wrapper.py @@ -0,0 +1,5 @@ +class VerifyWrapper(object): + """A Pact Verifier Wrapper.""" + + def verify(self): + pass diff --git a/pact/message_pact.py b/pact/message_pact.py index 7f67dc14f..b8190d56b 100644 --- a/pact/message_pact.py +++ b/pact/message_pact.py @@ -171,6 +171,7 @@ def write_to_pact_file(self): ] self._message_process = Popen(command) + self._message_process.wait() def _insert_message_if_complete(self): """ diff --git a/pact/message_provider.py b/pact/message_provider.py index d3da7ee57..4774fc84a 100644 --- a/pact/message_provider.py +++ b/pact/message_provider.py @@ -115,6 +115,25 @@ def verify(self): return_code, _ = verifier.verify_pacts(pact_files, verbose=False) assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + def verify_with_broker(self, enable_pending=False, include_wip_pacts_since=None, **kwargs): + """Use Broker to verify. + + Args: + broker_username ([String]): broker username + broker_password ([String]): broker password + broker_url ([String]): url of broker + enable_pending ([Boolean]) + include_wip_pacts_since ([String]) + publish_version ([String]) + + """ + verifier = Verifier(provider=self.provider, + provider_base_url=self._proxy_url()) + + return_code, _ = verifier.verify_with_broker(enable_pending, include_wip_pacts_since, **kwargs) + + assert (return_code == 0), f'Expected returned_code = 0, actual = {return_code}' + def __enter__(self): """ Enter a Python context. diff --git a/pact/pact.py b/pact/pact.py index a6ec2cdec..ef5600c8f 100644 --- a/pact/pact.py +++ b/pact/pact.py @@ -264,7 +264,7 @@ def verify(self): """ self._interactions = [] resp = requests.get( - self.uri + "/interactions/verification", headers=self.HEADERS, verify=False + self.uri + "/interactions/verification", headers=self.HEADERS, verify=False, allow_redirects=True ) assert resp.status_code == 200, resp.text resp = requests.post(self.uri + "/pact", headers=self.HEADERS, verify=False) diff --git a/pact/verifier.py b/pact/verifier.py index a3475bb53..ba4dfd9ea 100644 --- a/pact/verifier.py +++ b/pact/verifier.py @@ -3,6 +3,7 @@ from pact.verify_wrapper import VerifyWrapper, path_exists, expand_directories + class Verifier(object): """A Pact Verifier.""" diff --git a/requirements_dev.txt b/requirements_dev.txt index 37d7d91ee..9341f1210 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -14,7 +14,8 @@ pytest-cov==2.11.1 requests==2.26 urllib3>=1.26.5 wheel==0.24.0 +requests>=2.5.0 six>=1.9.0 -fastapi==0.67.0 -uvicorn==0.14.0 cffi==1.14.6 +pytest-httpserver==1.0.1 +uvicorn==0.14.0 \ No newline at end of file diff --git a/script/generate_verifier_args.py b/script/generate_verifier_args.py new file mode 100755 index 000000000..191422938 --- /dev/null +++ b/script/generate_verifier_args.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +""" +Generate Verifier args class + +This script generates the python source for a class to be used for the Pact +verifier. It works by parsing the JSON produced by the Rust Pact FFI library +function "pactffi_verifier_cli_args". + +Each argument available to the Rust Verifier is added as a line, along with the +description provided from Rust. This covers the single option argument, bool +arguments, and lists of options. +""" +import json +from pathlib import Path +from typing import List +from typing import Optional + +from pact.ffi.verifier import Verifier + + +def generate_verifier_args_json(pact_ffi_args_json: str) -> None: + """Call the Rust Pact FFI library to identify the args, and write to a file""" + arguments = Verifier()._cli_args_raw() + + with open(pact_ffi_args_json, "w") as f: + f.writelines(json.dumps(arguments, indent=2)) + + +def generate_verifier_args_source(pact_ffi_args_json: str) -> None: + """Take the generated JSON file, and construct a quick and dirty class""" + cli_json_path = Path.cwd().joinpath(pact_ffi_args_json) + with open(cli_json_path) as json_file: + data = json.load(json_file) + + list_options = [ + (attribute["long"].replace("-", "_"), Optional[List[str]], attribute["help"], "field(default_factory=list)") + for attribute in data.get("options") + if attribute["multiple"] + ] + str_options = [ + (attribute["long"].replace("-", "_"), Optional[str], attribute["help"], None) + for attribute in data.get("options") + if not attribute["multiple"] + ] + bool_options = [ + (attribute["long"].replace("-", "_"), Optional[bool], attribute["help"], None) + for attribute in data.get("flags") + ] + + nl = "\n" + lines = ( + f"import typing{nl}" + f"from dataclasses import dataclass, field{nl}" + f"{nl}" + f"{nl}" + f"@dataclass{nl}" + f"class VerifierArgs:{nl}" + f' """Auto-generated class, containing the arguments available to the pact verifier."""{nl}' + f"{nl}" + f'{nl.join([f" # {option[2]}{nl} {option[0]}: {option[1]} = {option[3]}{nl}" for option in str_options + list_options + bool_options])}' + "" + ) + print(lines) + + with open("pact/ffi/verifier_args.py", "w") as f: + f.writelines(lines) + + +if __name__ == "__main__": + pact_ffi_args_json = "examples/ffi/pact_ffi_verifier_args.json" + + generate_verifier_args_json(pact_ffi_args_json) + generate_verifier_args_source(pact_ffi_args_json) diff --git a/setup.py b/setup.py index 9d0e4dc1a..76c506d9f 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,12 @@ """pact-python PyPI Package.""" - +import gzip import os import platform +import shutil import sys import tarfile - +from distutils.command.sdist import sdist as sdist_orig +from typing import NamedTuple from zipfile import ZipFile import shutil import gzip @@ -14,21 +16,59 @@ from setuptools.command.install import install from urllib.request import urlopen - IS_64 = sys.maxsize > 2 ** 32 -PACT_STANDALONE_VERSION = '1.88.51' -PACT_FFI_VERSION = '0.0.0' +PACT_STANDALONE_VERSION = "1.88.51" +PACT_STANDALONE_SUFFIXES = ["osx.tar.gz", "linux-x86_64.tar.gz", "linux-x86.tar.gz", "win32.zip"] +PACT_FFI_VERSION = "0.0.3" +PACT_FFI_FILENAMES = [ + "libpact_ffi-linux-x86_64.so.gz", + "libpact_ffi-osx-aarch64-apple-darwin.dylib.gz", + "libpact_ffi-osx-x86_64.dylib.gz", + "pact_ffi-windows-x86_64.dll.gz", +] +PACT_RUBY_FILENAME = "pact-{version}-{suffix}" here = os.path.abspath(os.path.dirname(__file__)) + +class Binary(NamedTuple): + filename: str # For example: "pact-1.2.3-linux-x86_64.tar.gz" + version: str # For example: "1.2.3" + suffix: str # For example: "linux-x86_64.tar.gz" + single_file: bool # True for Pact Rust FFI where we have one library file + + about = {} with open(os.path.join(here, "pact", "__version__.py")) as f: exec(f.read(), about) -class PactPythonDevelopCommand(develop): +class sdist(sdist_orig): + """ + Subclass sdist so that we can download all standalone ruby applications + into ./pact/bin so our users receive all the binaries on pip install. """ - Custom develop mode installer for pact-python. + + def run(self): + package_bin_path = os.path.join(os.path.dirname(__file__), "pact", "bin") + + if os.path.exists(package_bin_path): + shutil.rmtree(package_bin_path, ignore_errors=True) + os.mkdir(package_bin_path) + + # Ruby binary + for suffix in PACT_STANDALONE_SUFFIXES: + filename = PACT_RUBY_FILENAME.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + download_binary(package_bin_path, filename, get_ruby_uri(suffix=suffix)) + + # Rust FFI library + for filename in PACT_FFI_FILENAMES: + download_binary(package_bin_path, filename, get_rust_uri(filename=filename)) + super().run() + + +class PactPythonDevelopCommand(develop): + """Custom develop mode installer for pact-python. When the package is installed using `python setup.py develop` or `pip install -e` it will download and unpack the appropriate Pact @@ -38,176 +78,251 @@ class PactPythonDevelopCommand(develop): def run(self): """Install ruby command.""" develop.run(self) - bin_path = os.path.join(os.path.dirname(__file__), 'pact', 'bin') - if not os.path.exists(bin_path): - os.mkdir(bin_path) + package_bin_path = os.path.join(os.path.dirname(__file__), "pact", "bin") + if not os.path.exists(package_bin_path): + os.mkdir(package_bin_path) - install_ruby_app(bin_path) - install_rust_app(bin_path) + # Ruby + install_binary(package_bin_path, download_bin_path=None, binary=ruby_app_binary()) + + # Rust + install_binary(package_bin_path, download_bin_path=None, binary=rust_lib_binary()) class PactPythonInstallCommand(install): - """ - Custom installer for pact-python. + """Custom installer for pact-python. Installs the Python package and unpacks the platform appropriate version of the Ruby mock service and provider verifier. + + User Options: + --bin-path An absolute folder path containing pre-downloaded pact binaries + that should be used instead of fetching from the internet. """ + user_options = install.user_options + [("bin-path=", None, None)] + + def initialize_options(self): + """Load our preconfigured options""" + install.initialize_options(self) + self.bin_path = None + + def finalize_options(self): + """Load provided CLI arguments into our options""" + install.finalize_options(self) + def run(self): """Install python binary.""" install.run(self) - bin_path = os.path.join(self.install_lib, 'pact', 'bin') - os.mkdir(bin_path) - install_ruby_app(bin_path) - install_rust_app(bin_path) + package_bin_path = os.path.join(self.install_lib, "pact", "bin") + if not os.path.exists(package_bin_path): + os.mkdir(package_bin_path) + + # Ruby + install_binary(package_bin_path, self.bin_path, binary=ruby_app_binary()) + + # Rust + install_binary(package_bin_path, download_bin_path=None, binary=rust_lib_binary()) + + +def get_ruby_uri(suffix) -> str: + """Determine the full URI to download the Ruby binary from.""" + uri = ( + "https://github.com/pact-foundation/pact-ruby-standalone/releases" + "/download/v{version}/pact-{version}-{suffix}" + ) + return uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + + +def get_rust_uri(filename) -> str: + """Determine the full URI to download the Rust binary from.""" + uri = ( + f"https://github.com/pact-foundation/pact-reference/releases" + f"/download/libpact_ffi-v{PACT_FFI_VERSION}/{filename}" + ) + return uri.format(version=PACT_STANDALONE_VERSION, filename=filename) + +def install_binary(package_bin_path, download_bin_path, binary: Binary): + """Installs the ruby standalone application for this OS. -def install_rust_app(bin_path): + :param package_bin_path: The path where we want our pact binaries unarchived. + :param download_bin_path: An optional path containing pre-downloaded pact binaries. + :param binary: Details of the zipped binary files required """ - Download the relevant rust binaries and install it for use. + print(f"-> install_binary({package_bin_path=}, {download_bin_path=}, {binary=})") + + if download_bin_path is not None: + # If a download_bin_path has been provided, but does not contain what we + # expect, do not continue + path = os.path.join(download_bin_path, binary.filename) + if not os.path.isfile(path): + raise RuntimeError("Could not find {} binary.".format(path)) + else: + if binary.single_file: + extract_gz(download_bin_path, package_bin_path, binary.filename) + else: + extract_ruby_app_binary(download_bin_path, package_bin_path, binary.filename) + else: + # Otherwise, download to the destination package_bin_path, skipping to + # just extract if we have it already + path = os.path.join(package_bin_path, binary.filename) + if not os.path.isfile(path): + download_binary(package_bin_path, binary.filename, uri=get_ruby_uri(binary.suffix)) + + if binary.single_file: + extract_gz(package_bin_path, package_bin_path, binary.filename) + else: + extract_ruby_app_binary(package_bin_path, package_bin_path, binary.filename) - :param bin_path: The path where binaries should be installed. + print("<- install_binary") + + +def ruby_app_binary() -> Binary: + """Determines the ruby app binary required for this OS. + + :return Details of the binary file required """ target_platform = platform.platform().lower() - if 'darwin' in target_platform or 'macos' in target_platform: - suffix = 'libpact_ffi-osx-x86_64' - elif 'linux' in target_platform: - suffix = 'libpact_ffi-linux-x86_64' - elif 'windows' in target_platform: - suffix = 'pact_ffi-windows-x86_64' + if "darwin" in target_platform or "macos" in target_platform: + suffix = "osx.tar.gz" + elif "linux" in target_platform and IS_64: + suffix = "linux-x86_64.tar.gz" + elif "linux" in target_platform: + suffix = "linux-x86.tar.gz" + elif "windows" in target_platform: + suffix = "win32.zip" else: - msg = ('Unfortunately, {} is not a supported platform. Only Linux,' - ' Windows, and OSX are currently supported.').format( - platform.platform()) + msg = ( + "Unfortunately, {} is not a supported platform. Only Linux," " Windows, and OSX are currently supported." + ).format(platform.platform()) raise Exception(msg) - if 'windows' in platform.platform().lower(): - fetch_lib(bin_path, suffix, 'dll') - fetch_lib(bin_path, suffix, 'dll.lib') - fetch_lib(bin_path, suffix, 'lib') + binary = PACT_RUBY_FILENAME.format(version=PACT_STANDALONE_VERSION, suffix=suffix) + return Binary(filename=binary, version=PACT_STANDALONE_VERSION, suffix=suffix, single_file=False) + +def rust_lib_binary() -> Binary: + """Determines the Rust FFI library binary required for this OS. + + :return Details of the binary file required + """ + target_platform = platform.platform().lower() + + if ("darwin" in target_platform or "macos" in target_platform) and "aarch64" in platform.machine(): + # TODO: Untested, can someone with the appropriate architecture verify? + binary = "libpact_ffi-osx-aarch64-apple-darwin.dylib.gz" + elif "darwin" in target_platform or "macos" in target_platform: + binary = "libpact_ffi-osx-x86_64.dylib.gz" + elif "linux" in target_platform and IS_64: + binary = "libpact_ffi-linux-x86_64.so.gz" + elif "windows" in target_platform: + binary = "pact_ffi-windows-x86_64.dll.gz" else: - fetch_lib(bin_path, suffix, 'a') - if 'darwin' in target_platform or 'macos' in target_platform: - fetch_lib(bin_path, suffix, 'dylib') + msg = ( + "Unfortunately, {} is not a supported platform. Only Linux x86_64," + " Windows, and OSX are currently supported." + ).format(platform.platform()) + raise Exception(msg) + + return Binary(filename=binary, version=PACT_STANDALONE_VERSION, suffix=None, single_file=True) - elif 'linux' in target_platform: - fetch_lib(bin_path, suffix, 'so') +def download_binary(path_to_download_to, filename, uri): + """Downloads `filename` into `path_to_download_to`. -def fetch_lib(bin_path, suffix, type): + :param path_to_download_to: The path where binaries should be downloaded. + :param filename: The filename that should be installed. + :param uri: The URI to download the file from. """ - Fetch rust binaries to the bin_path. + print(f"-> download_binary({path_to_download_to=}, {filename=}, {uri=})") - :param bin_path: The path where binaries should be installed. - :param suffix: The suffix filenamne unique to this platform (e.g. libpact_ffi-osx-x86_64). - "param type: The type of library (e.g. so|a|dll|dylib) - Raises: - RuntimeError: [description] + if sys.version_info.major == 2: + from urllib import urlopen + else: + from urllib.request import urlopen - """ - dest = os.path.join(bin_path, f'{suffix}.{type}') - zipped = os.path.join(bin_path, f'{suffix}.{type}.gz') - uri = ( - f"https://github.com/pact-foundation/pact-reference/releases" - f"/download/libpact_ffi-v{PACT_FFI_VERSION}/{suffix}.{type}.gz") + path = os.path.join(path_to_download_to, filename) resp = urlopen(uri) - with open(zipped, 'wb') as f: + with open(path, "wb") as f: if resp.code == 200: f.write(resp.read()) else: - raise RuntimeError( - 'Received HTTP {} when downloading {}'.format( - resp.code, resp.url)) + raise RuntimeError("Received HTTP {} when downloading {}".format(resp.code, resp.url)) - with gzip.open(zipped) as g, open(dest, 'wb') as f_out: - shutil.copyfileobj(g, f_out) + print("<- download_binary") -def install_ruby_app(bin_path): - """ - Download a Ruby application and install it for use. +def extract_ruby_app_binary(source: str, destination: str, binary: str): + """Extracts the ruby app binary from `source` into `destination`. - :param bin_path: The path where binaries should be installed. + :param source: The location of the binary to unarchive. + :param destination: The location to unarchive to. + :param binary: The binary that needs to be unarchived. """ - target_platform = platform.platform().lower() - uri = ('https://github.com/pact-foundation/pact-ruby-standalone/releases' - '/download/v{version}/pact-{version}-{suffix}') - - if 'darwin' in target_platform or 'macos' in target_platform: - suffix = 'osx.tar.gz' - elif 'linux' in target_platform and IS_64: - suffix = 'linux-x86_64.tar.gz' - elif 'linux' in target_platform: - suffix = 'linux-x86.tar.gz' - elif 'windows' in target_platform: - suffix = 'win32.zip' - else: - msg = ('Unfortunately, {} is not a supported platform. Only Linux,' - ' Windows, and OSX are currently supported.').format( - platform.platform()) - raise Exception(msg) - - path = os.path.join(bin_path, suffix) - resp = urlopen(uri.format(version=PACT_STANDALONE_VERSION, suffix=suffix)) - with open(path, 'wb') as f: - if resp.code == 200: - f.write(resp.read()) - else: - raise RuntimeError( - 'Received HTTP {} when downloading {}'.format( - resp.code, resp.url)) + print(f"-> extract_ruby_app_binary({source=}, {destination=}, {binary=})") - if 'windows' in platform.platform().lower(): + path = os.path.join(source, binary) + if "windows" in platform.platform().lower(): with ZipFile(path) as f: - f.extractall(bin_path) + f.extractall(destination) else: with tarfile.open(path) as f: - f.extractall(bin_path) + f.extractall(destination) + + print("<- extract_ruby_app_binary") + + +def extract_gz(source: str, destination: str, binary: str): + print(f"-> extract_gz({source=}, {destination=}, {binary=})") + + path = os.path.join(source, binary) + dest = os.path.splitext(os.path.join(destination, binary))[0] + + with gzip.open(path, "rb") as f_in, open(dest, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + print("<- extract_gz") def read(filename): """Read file contents.""" path = os.path.realpath(os.path.join(os.path.dirname(__file__), filename)) - with open(path, 'rb') as f: - return f.read().decode('utf-8') + with open(path, "rb") as f: + return f.read().decode("utf-8") dependencies = [ - 'cffi==1.14.6', - 'click>=2.0.0', - 'psutil>=2.0.0', - 'requests>=2.5.0', - 'six>=1.9.0', - 'fastapi==0.67.0', - 'urllib3>=1.26.5', - 'uvicorn==0.14.0' + "cffi==1.14.6", + "click>=2.0.0", + "psutil>=2.0.0", + "requests>=2.5.0", + "six>=1.9.0", + "fastapi>=0.67.0", + "urllib3>=1.26.5", + "uvicorn>=0.14.0", ] -if __name__ == '__main__': +if __name__ == "__main__": setup( - cmdclass={ - 'develop': PactPythonDevelopCommand, - 'install': PactPythonInstallCommand}, - name='pact-python', - version=about['__version__'], - description=( - 'Tools for creating and verifying consumer driven ' - 'contracts using the Pact framework.'), - long_description=read('README.md'), - long_description_content_type='text/markdown', - author='Matthew Balvanz', - author_email='matthew.balvanz@workiva.com', - url='https://github.com/pact-foundation/pact-python', - entry_points=''' + cmdclass={"develop": PactPythonDevelopCommand, "install": PactPythonInstallCommand, "sdist": sdist}, + name="pact-python", + version=about["__version__"], + description=("Tools for creating and verifying consumer driven " "contracts using the Pact framework."), + long_description=read("README.md"), + long_description_content_type="text/markdown", + author="Matthew Balvanz", + author_email="matthew.balvanz@workiva.com", + url="https://github.com/pact-foundation/pact-python", + entry_points=""" [console_scripts] - pact-verifier=pact.cli.verify:main - ''', + pact-verifier=pact.ffi.cli.verify:main + """, install_requires=dependencies, - packages=['pact', 'pact.cli'], - package_data={'pact': ['bin/*']}, - package_dir={'pact': 'pact'}, - license='MIT License') + packages=["pact", "pact.cli"], + package_data={"pact": ["bin/*"]}, + package_dir={"pact": "pact"}, + license="MIT License", + ) diff --git a/tests/ffi/cli/test_verify.py b/tests/ffi/cli/test_verify.py new file mode 100644 index 000000000..5f8151c16 --- /dev/null +++ b/tests/ffi/cli/test_verify.py @@ -0,0 +1,82 @@ +from pact.ffi.cli.verify import main +from pact.ffi.verifier import Verifier, VerifyStatus + + +def test_cli_args(): + """Make sure we have at least some arguments and they all have the required + long version and help.""" + args = Verifier().cli_args() + + assert len(args.options) > 0 + assert len(args.flags) > 0 + assert all([arg.help is not None for arg in args.options]) + assert all([arg.long is not None for arg in args.options]) + assert all([arg.help is not None for arg in args.flags]) + assert all([arg.long is not None for arg in args.flags]) + + +def test_cli_args_cautious(cli_options, cli_flags): + """ + If desired, we can keep track of the list of arguments supported by the FFI + CLI, and then at least be alerted if there is a change (this test will fail). + + We don't really *need* to test against this, but it might be nice to know to + avoid any surprises. + """ + args = Verifier().cli_args() + + assert len(args.options) == len(cli_options) + assert all([arg.long in cli_options for arg in args.options]) + + assert len(args.flags) == len(cli_flags) + assert all([arg.long in cli_flags for arg in args.flags]) + + +def test_cli_help(runner): + """Click should return the usage information.""" + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert result.output.startswith("Usage: pact-verifier [OPTIONS]") + + +def test_cli_no_args(runner): + """If no args are provided, but Click passes the default, we still want help.""" + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert result.output.startswith("Usage: pact-verifier [OPTIONS]") + + +def test_cli_verify_success(runner, httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is as expected, so the verify succeeds. + """ + body = {"answer": 42} # 42 will be returned as an int, as expected + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + result = runner.invoke(main, args) + + assert VerifyStatus(result.exit_code) == VerifyStatus.SUCCESS + + +def test_cli_verify_failure(runner, httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is NOT as expected (str not int), so the verify fails. + """ + body = {"answer": "42"} # 42 will be returned as a str, which will fail + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + result = runner.invoke(main, args) + + assert VerifyStatus(result.exit_code) == VerifyStatus.VERIFIER_FAILED diff --git a/tests/ffi/conftest.py b/tests/ffi/conftest.py new file mode 100644 index 000000000..a708bd326 --- /dev/null +++ b/tests/ffi/conftest.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def runner(): + return CliRunner() + + +# CLI option arguments supported, correct as of Pact FFI 0.0.2. +@pytest.fixture +def cli_options(): + return [ + "loglevel", + "file", + "dir", + "url", + "broker-url", + "hostname", + "port", + "scheme", + "provider-name", + "state-change-url", + "filter-description", + "filter-state", + "filter-no-state", + "filter-consumer", + "user", + "password", + "token", + "provider-version", + "build-url", + "provider-tags", + "base-path", + "consumer-version-tags", + "consumer-version-selectors", + "include-wip-pacts-since", + "request-timeout", + ] + + +# CLI flag arguments supported, correct as of Pact FFI 0.0.2. +@pytest.fixture +def cli_flags(): + return ["state-change-as-query", "state-change-teardown", "publish", "disable-ssl-verification", "enable-pending"] + + +@pytest.fixture +def pacts_dir(): + """Find the correct pacts dir, depending on where the tests are run from""" + relative = "../examples/pacts/" if Path.cwd().name == "tests" else "examples/pacts" + return Path.cwd().joinpath(relative) + + +@pytest.fixture +def pact_consumer_one_pact_provider_one_path(pacts_dir): + """Provide the full path to a JSON pact for tests""" + pact = pacts_dir.joinpath("pact-consumer-one-pact-provider-one.json") + assert pact.is_file() + return str(pact) diff --git a/tests/ffi/test_verifier.py b/tests/ffi/test_verifier.py new file mode 100644 index 000000000..b60fd12bf --- /dev/null +++ b/tests/ffi/test_verifier.py @@ -0,0 +1,102 @@ +import os +from pathlib import Path + +from pact.ffi.verifier import Verifier, VerifyStatus + + +def test_version(): + result = Verifier().version() + assert result == "0.0.3" + + +def test_verify_no_args(): + result = Verifier().verify(args=None) + assert VerifyStatus(result.return_code) == VerifyStatus.NULL_POINTER + + +def test_verify_help(): + result = Verifier().verify(args="--help") + assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS + assert "kind: HelpDisplayed" in "\n".join(result.logs) + + +def test_verify_version(): + result = Verifier().verify(args="--version") + assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS + assert "kind: VersionDisplayed" in "\n".join(result.logs) + + +def test_verify_invalid_args(): + """Verify we get an expected return code and log content to invalid args. + + Example output, with TRACE (default) logs: + [TRACE][mio::poll] registering event source with poller: token=Token(0), interests=READABLE | WRITABLE + [ERROR][pact_ffi::verifier::verifier] error verifying Pact: "error: Found argument 'Your argument is invalid' which wasn't expected, or isn't valid in this context\n\nUSAGE:\n pact_verifier_cli [FLAGS] [OPTIONS] --broker-url ... --dir ... --file ... --provider-name --url ...\n\nFor more information try --help" Error { message: "error: Found argument 'Your argument is invalid' which wasn't expected, or isn't valid in this context\n\nUSAGE:\n pact_verifier_cli [FLAGS] [OPTIONS] --broker-url ... --dir ... --file ... --provider-name --url ...\n\nFor more information try --help", kind: UnknownArgument, info: Some(["Your argument is invalid"]) } + """ + result = Verifier().verify(args="Your argument is invalid") + assert VerifyStatus(result.return_code) == VerifyStatus.INVALID_ARGS + assert "kind: UnknownArgument" in "\n".join(result.logs) + assert len(result.logs) == 1 # 1 for only the ERROR log, otherwise will be 2 + + +def test_verify_success(httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is as expected, so the verify succeeds. + """ + body = {"answer": 42} # 42 will be returned as an int, as expected + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args_list = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + args = "\n".join(args_list) + result = Verifier().verify(args=args) + assert VerifyStatus(result.return_code) == VerifyStatus.SUCCESS + + +def test_verify_failure(httpserver, pact_consumer_one_pact_provider_one_path): + """ + Use the FFI library to verify a simple pact, using a mock httpserver. + In this case the response is NOT as expected (str not int), so the verify fails. + """ + body = {"answer": "42"} # 42 will be returned as a str, which will fail + endpoint = "/test-provider-one" + httpserver.expect_request(endpoint).respond_with_json(body) + + args_list = [ + f"--port={httpserver.port}", + f"--file={pact_consumer_one_pact_provider_one_path}", + ] + args = "\n".join(args_list) + result = Verifier().verify(args=args) + assert VerifyStatus(result.return_code) == VerifyStatus.VERIFIER_FAILED + + +""" +Original verifier tests. Moving as they are implemented via FFI instead. + +TODO: + def test_verifier_with_provider_and_files(self, mock_path_exists, mock_wrapper): + def test_verifier_with_provider_and_files_passes_consumer_selctors(self, mock_path_exists, mock_wrapper): + def test_validate_on_publish_results(self): + def test_publish_on_success(self, mock_path_exists, mock_wrapper): + def test_raises_error_on_missing_pact_files(self, mock_path_exists): + def test_expand_directories_called_for_pacts(self, mock_path_exists, mock_expand_dir, mock_wrapper): + def test_passes_enable_pending_flag_value(self, mock_wrapper): + def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + def test_verifier_with_broker(self, mock_wrapper): + def test_verifier_and_pubish_with_broker(self, mock_wrapper): + def test_verifier_with_broker_passes_consumer_selctors(self, mock_wrapper): + def test_publish_on_success(self, mock_path_exists, mock_wrapper): + def test_passes_enable_pending_flag_value(self, mock_wrapper): + def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapper): + +Done: + def test_version(self): + +Issues: + +""" diff --git a/tests/test_message_provider.py b/tests/test_message_provider.py index dd38c06d3..8b71c3bdd 100644 --- a/tests/test_message_provider.py +++ b/tests/test_message_provider.py @@ -6,6 +6,7 @@ from pact.message_provider import MessageProvider from pact import message_provider as message_provider + class MessageProviderTestCase(TestCase): def _mock_response( self, @@ -33,6 +34,13 @@ def setUp(self): 'a document created successfully': self.message_handler } ) + self.options = { + 'broker_username': "test", + 'broker_password': "test", + 'broker_url': "http://localhost", + 'publish_version': '3', + 'publish_verification_results': False + } def test_init(self): self.assertIsInstance(self.provider, MessageProvider) @@ -50,6 +58,17 @@ def test_verify(self, mock_verify_pacts): assert mock_verify_pacts.call_count == 1 mock_verify_pacts.assert_called_with(f'{self.provider.pact_dir}/{self.provider._pact_file()}', verbose=False) + @patch('pact.Verifier.verify_with_broker', return_value=(0, 'logs')) + def test_verify_with_broker(self, mock_verify_pacts): + self.provider.verify_with_broker(**self.options) + + assert mock_verify_pacts.call_count == 1 + mock_verify_pacts.assert_called_with(False, None, broker_username="test", + broker_password="test", + broker_url="http://localhost", + publish_version='3', + publish_verification_results=False) + class MessageProviderContextManagerTestCase(MessageProviderTestCase): def setUp(self): diff --git a/tests/test_pact.py b/tests/test_pact.py index 19501e375..05b7bb08b 100644 --- a/tests/test_pact.py +++ b/tests/test_pact.py @@ -489,7 +489,8 @@ def setUp(self): 'get', 'http://localhost:1234/interactions/verification', headers={'X-Pact-Mock-Service': 'true'}, verify=False, - params=None) + params=None, + allow_redirects=True) self.post_publish_pacts_call = call( 'post', 'http://localhost:1234/pact', diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 961ad63aa..b787203e3 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -7,12 +7,14 @@ from pact.verifier import Verifier from pact.verify_wrapper import VerifyWrapper + def assertVerifyCalled(mock_wrapper, *pacts, **options): tc = unittest.TestCase() tc.assertEqual(mock_wrapper.call_count, 1) mock_wrapper.assert_called_once_with(*pacts, **options) + class VerifierPactsTestCase(TestCase): def setUp(self): @@ -133,6 +135,7 @@ def test_passes_include_wip_pacts_since_value(self, mock_path_exists, mock_wrapp mock_wrapper.call_args.kwargs, ) + class VerifierBrokerTestCase(TestCase): def setUp(self):