From effd6c85914209757a038ecb0cf7e6f83cba7c71 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Fri, 25 Apr 2025 14:45:22 +0530 Subject: [PATCH 1/7] APP-6435: Added support for `vcrpy` test utilities to mock `HTTP` interactions with 3rd-party `APIs` --- pyatlan/test_utils/__init__.py | 2 +- pyatlan/test_utils/base_vcr.py | 280 +++++++++++++++++++++++++++++++++ pyatlan/utils.py | 7 + requirements-dev.txt | 1 + 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 pyatlan/test_utils/base_vcr.py diff --git a/pyatlan/test_utils/__init__.py b/pyatlan/test_utils/__init__.py index e2a1ec59d..b82aec9f9 100644 --- a/pyatlan/test_utils/__init__.py +++ b/pyatlan/test_utils/__init__.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Copyright 2024 Atlan Pte. Ltd. +# Copyright 2025 Atlan Pte. Ltd. import logging import random from os import path diff --git a/pyatlan/test_utils/base_vcr.py b/pyatlan/test_utils/base_vcr.py new file mode 100644 index 000000000..4d76d950c --- /dev/null +++ b/pyatlan/test_utils/base_vcr.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 Atlan Pte. Ltd. + +import pkg_resources # type: ignore[import-untyped] + +from pyatlan.utils import DependencyNotFoundError + +try: + # Check if pytest-vcr plugin is installed + pkg_resources.get_distribution("pytest-vcr") +except pkg_resources.DistributionNotFound: + raise DependencyNotFoundError("pytest-vcr") + +import json +import os +from typing import Any, Dict, Union + +import pytest +import yaml # type: ignore[import-untyped] + + +class LiteralBlockScalar(str): + """Formats the string as a literal block scalar, preserving whitespace and + without interpreting escape characters""" + + +def literal_block_scalar_presenter(dumper, data): + """Represents a scalar string as a literal block, via '|' syntax""" + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + + +yaml.add_representer(LiteralBlockScalar, literal_block_scalar_presenter) + + +def process_string_value(string_value): + """Pretty-prints JSON or returns long strings as a LiteralBlockScalar""" + try: + json_data = json.loads(string_value) + return LiteralBlockScalar(json.dumps(json_data, indent=2)) + except (ValueError, TypeError): + if len(string_value) > 80: + return LiteralBlockScalar(string_value) + return string_value + + +def convert_body_to_literal(data): + """Searches the data for body strings, attempting to pretty-print JSON""" + if isinstance(data, dict): + for key, value in data.items(): + # Handle response body case (e.g: response.body.string) + if key == "body" and isinstance(value, dict) and "string" in value: + value["string"] = process_string_value(value["string"]) + + # Handle request body case (e.g: request.body) + elif key == "body" and isinstance(value, str): + data[key] = process_string_value(value) + + else: + convert_body_to_literal(value) + + elif isinstance(data, list): + for idx, choice in enumerate(data): + data[idx] = convert_body_to_literal(choice) + + return data + + +class VCRPrettyPrintYamlJSONBody: + """This makes request and response YAML JSON body recordings more readable.""" + + @staticmethod + def serialize(cassette_dict): + cassette_dict = convert_body_to_literal(cassette_dict) + return yaml.dump(cassette_dict, default_flow_style=False, allow_unicode=True) + + @staticmethod + def deserialize(cassette_string): + return yaml.safe_load(cassette_string) + + +class VCRPrettyPrintJSONBody: + """Makes request and response JSON body recordings more readable.""" + + @staticmethod + def _parse_json_body( + body: Union[str, bytes, None], + ) -> Union[Dict[str, Any], str, None, bytes]: + """Parse JSON body if possible, otherwise return the original body.""" + if body is None: + return None + + # Convert bytes to string if needed + if isinstance(body, bytes): + try: + body = body.decode("utf-8") + except UnicodeDecodeError: + return body # Return original if can't decode + + # If it's a string, try to parse as JSON + if isinstance(body, str): + try: + return json.loads(body) + except json.JSONDecodeError: + return body # Return original if not valid JSON + + return body # Return original for other types + + @staticmethod + def serialize(cassette_dict: dict) -> str: + """ + Converts body strings to parsed JSON objects for better readability when possible. + """ + # Safety check for cassette_dict + if not cassette_dict or not isinstance(cassette_dict, dict): + cassette_dict = {} + + interactions = cassette_dict.get("interactions", []) or [] + + for interaction in interactions: + if not interaction: + continue + + # Handle response body + response = interaction.get("response") or {} + body_container = response.get("body") + if isinstance(body_container, dict) and "string" in body_container: + parsed_body = VCRPrettyPrintJSONBody._parse_json_body( + body_container["string"] + ) + if isinstance(parsed_body, dict): + # Replace string field with parsed_json field + response["body"] = {"parsed_json": parsed_body} + + # Handle request body + request = interaction.get("request") or {} + body_container = request.get("body") + if isinstance(body_container, dict) and "string" in body_container: + parsed_body = VCRPrettyPrintJSONBody._parse_json_body( + body_container["string"] + ) + if isinstance(parsed_body, dict): + # Replace string field with parsed_json field + request["body"] = {"parsed_json": parsed_body} + + # Serialize the final dictionary into a JSON string with pretty formatting + try: + return json.dumps(cassette_dict, indent=2, ensure_ascii=False) + "\n" + except TypeError as exc: + raise TypeError( + "Does this HTTP interaction contain binary data? " + "If so, use a different serializer (like the YAML serializer)." + ) from exc + + @staticmethod + def deserialize(cassette_string: str) -> dict: + """ + Deserializes a JSON string into a dictionary and converts + parsed_json fields back to string fields. + """ + # Safety check for cassette_string + if not cassette_string: + return {} + + try: + cassette_dict = json.loads(cassette_string) + except json.JSONDecodeError: + return {} + + # Convert parsed_json back to string format + interactions = cassette_dict.get("interactions", []) or [] + + for interaction in interactions: + if not interaction: + continue + + # Handle response body + response = interaction.get("response") or {} + body_container = response.get("body") + if isinstance(body_container, dict) and "parsed_json" in body_container: + json_body = body_container["parsed_json"] + response["body"] = {"string": json.dumps(json_body)} + + # Handle request body + request = interaction.get("request") or {} + body_container = request.get("body") + if isinstance(body_container, dict) and "parsed_json" in body_container: + json_body = body_container["parsed_json"] + request["body"] = {"string": json.dumps(json_body)} + + return cassette_dict + + +class VCRRemoveAllHeaders: + """ + A class responsible for removing all headers from requests and responses. + This can be useful for scenarios where headers are not needed for matching or comparison + in VCR (Virtual Cassette Recorder) interactions, such as when recording or replaying HTTP requests. + """ + + @staticmethod + def remove_all_request_headers(request): + # Save only what's necessary for matching + request.headers = {} + return request + + @staticmethod + def remove_all_response_headers(response): + # Save only what's necessary for matching + response["headers"] = {} + return response + + +class BaseVCR: + """ + A base class for configuring VCR (Virtual Cassette Recorder) + for HTTP request/response recording and replaying. + + This class provides pytest fixtures to set up the VCR configuration + and custom serializers for JSON and YAML formats. + It also handles cassette directory configuration. + """ + + _CASSETTES_DIR = None + + @pytest.fixture(scope="module") + def vcr(self, vcr): + """ + Registers custom serializers for VCR and returns the VCR instance. + + The method registers two custom serializers: + - "pretty-json" for pretty-printing JSON responses. + - "pretty-yaml" for pretty-printing YAML responses. + + :param vcr: The VCR instance provided by the pytest-vcr plugin + :returns: modified VCR instance with custom serializers registered + """ + vcr.register_serializer("pretty-json", VCRPrettyPrintJSONBody) + vcr.register_serializer("pretty-yaml", VCRPrettyPrintYamlJSONBody) + return vcr + + @pytest.fixture(scope="module") + def vcr_config(self): + """ + Provides the VCR configuration dictionary. + + The configuration includes default options for the recording mode, + serializer, response decoding, and filtering headers. + This configuration is used to set up VCR behavior during tests. + + :returns: a dictionary with VCR configuration options + """ + return { + # More config options can be found at: + # https://vcrpy.readthedocs.io/en/latest/configuration.html#configuration + "record_mode": "once", # (default: "once", "always", "none", "new_episodes") + "serializer": "pretty-yaml", # (default: "yaml") + "decode_compressed_response": True, # Decode compressed responses + # (optional) Replace the Authorization request header with "**REDACTED**" in cassettes + # "filter_headers": [("authorization", "**REDACTED**")], + "before_record_request": VCRRemoveAllHeaders.remove_all_request_headers, + "before_record_response": VCRRemoveAllHeaders.remove_all_response_headers, + } + + @pytest.fixture(scope="module") + def vcr_cassette_dir(self, request): + """ + Provides the directory path for storing VCR cassettes. + + If a custom cassette directory is set in the class, it is used; + otherwise, the default directory structure is created under "tests/cassettes". + The directory path will be based on the module name. + + :param request: request object which provides metadata about the test + + :returns: directory path for storing cassettes + """ + # Set self._CASSETTES_DIR or use the default directory path based on the test module name + return self._CASSETTES_DIR or os.path.join( + "tests/cassettes", request.module.__name__ + ) diff --git a/pyatlan/utils.py b/pyatlan/utils.py index c15b30388..afc0bc16e 100644 --- a/pyatlan/utils.py +++ b/pyatlan/utils.py @@ -471,3 +471,10 @@ def validate_single_required_field(field_names: List[str], values: List[Any]): raise ValueError( f"Only one of the following parameters are allowed: {', '.join(names)}" ) + + +class DependencyNotFoundError(Exception): + def __init__(self, dependency): + super().__init__( + f"{dependency} is not installed, but it is required to use this module. Please install {dependency}." + ) diff --git a/requirements-dev.txt b/requirements-dev.txt index dec693508..eaaa0e64e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ mypy~=1.9.0 ruff~=0.9.9 types-requests~=2.32.0.20241016 pytest~=8.3.4 +pytest-vcr~=1.0.2 pytest-order~=1.3.0 pytest-timer[termcolor]~=1.0.0 pytest-sugar~=1.0.0 From 69f757e2b6442cd6df7b6d8af2659876cb811fa8 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Fri, 25 Apr 2025 16:23:25 +0530 Subject: [PATCH 2/7] [deps] Pinned `types-requests` and `vcrpy` to avoid compatibility issues with `py 3.8` and `py 3.9` versions --- requirements-dev.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index eaaa0e64e..221a00d69 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,13 @@ mypy~=1.9.0 ruff~=0.9.9 -types-requests~=2.32.0.20241016 +# [PINNED] for Python 3.8 compatibility +# higher versions require urllib3>=2.0 +types-requests~=2.31.0.6 pytest~=8.3.4 pytest-vcr~=1.0.2 +# [PINNED] to v6.x since vcrpy>=7.0 requires urllib3>=2.0 +# which breaks compatibility with Python 3.8 +vcrpy~=6.0.2 pytest-order~=1.3.0 pytest-timer[termcolor]~=1.0.0 pytest-sugar~=1.0.0 From e4cfa9afca92a62c417cb07a0c5b428616a5b29f Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Fri, 25 Apr 2025 16:27:14 +0530 Subject: [PATCH 3/7] [change] Added additional checks for `pytest-vcr` and `vcrpy` dependencies --- pyatlan/errors.py | 11 +++++++++++ pyatlan/test_utils/base_vcr.py | 20 +++++++++++++++++--- pyatlan/utils.py | 7 ------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pyatlan/errors.py b/pyatlan/errors.py index edb45efec..8dadf1d9d 100644 --- a/pyatlan/errors.py +++ b/pyatlan/errors.py @@ -51,6 +51,17 @@ def __str__(self): ) +class DependencyNotFoundError(Exception): + """ + Raised when a required external dependency is not installed. + + This exception is typically used to indicate that an optional library + or plugin needed for a specific feature is missing. + """ + + pass + + class ApiConnectionError(AtlanError): """Error that occurs when there is an intermittent issue with the API, such as a network outage or an inability to connect due to an incorrect URL.""" diff --git a/pyatlan/test_utils/base_vcr.py b/pyatlan/test_utils/base_vcr.py index 4d76d950c..4885c5ca2 100644 --- a/pyatlan/test_utils/base_vcr.py +++ b/pyatlan/test_utils/base_vcr.py @@ -3,13 +3,27 @@ import pkg_resources # type: ignore[import-untyped] -from pyatlan.utils import DependencyNotFoundError +from pyatlan.errors import DependencyNotFoundError +# Check if pytest-vcr plugin is installed try: - # Check if pytest-vcr plugin is installed pkg_resources.get_distribution("pytest-vcr") except pkg_resources.DistributionNotFound: - raise DependencyNotFoundError("pytest-vcr") + raise DependencyNotFoundError( + "pytest-vcr plugin is not installed. Please install pytest-vcr." + ) + +# Check if vcrpy is installed and ensure the version is 6.0.x +try: + vcr_version = pkg_resources.get_distribution("vcrpy").version + if not vcr_version.startswith("6.0"): + raise DependencyNotFoundError( + f"vcrpy version 6.0.x is required, but found {vcr_version}. Please install the correct version." + ) +except pkg_resources.DistributionNotFound: + raise DependencyNotFoundError( + "vcrpy version 6.0.x is not installed. Please install vcrpy version 6.0.x." + ) import json import os diff --git a/pyatlan/utils.py b/pyatlan/utils.py index afc0bc16e..c15b30388 100644 --- a/pyatlan/utils.py +++ b/pyatlan/utils.py @@ -471,10 +471,3 @@ def validate_single_required_field(field_names: List[str], values: List[Any]): raise ValueError( f"Only one of the following parameters are allowed: {', '.join(names)}" ) - - -class DependencyNotFoundError(Exception): - def __init__(self, dependency): - super().__init__( - f"{dependency} is not installed, but it is required to use this module. Please install {dependency}." - ) From 85a48e11dc16237b91db689a3a2b5679f9dd6808 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Mon, 28 Apr 2025 17:46:27 +0530 Subject: [PATCH 4/7] [tests] Added VCR-based integration test examples for JSON and YAML configurations - Introduced integration tests using VCR for recording and replaying HTTP interactions with JSON and YAML formats. - Applied class-specific VCR configurations to handle both JSON and YAML serializers for different test scenarios. --- pyatlan/test_utils/base_vcr.py | 64 +++++++++--------- tests/unit/test_base_vcr_json.py | 65 +++++++++++++++++++ tests/unit/test_base_vcr_yaml.py | 61 +++++++++++++++++ .../TestBaseVCRJSON.test_httpbin_delete.yaml | 39 +++++++++++ .../TestBaseVCRJSON.test_httpbin_get.yaml | 36 ++++++++++ .../TestBaseVCRJSON.test_httpbin_post.yaml | 43 ++++++++++++ .../TestBaseVCRJSON.test_httpbin_put.yaml | 42 ++++++++++++ .../TestBaseVCRYAML.test_httpbin_delete.yaml | 31 +++++++++ .../TestBaseVCRYAML.test_httpbin_get.yaml | 28 ++++++++ .../TestBaseVCRYAML.test_httpbin_post.yaml | 39 +++++++++++ .../TestBaseVCRYAML.test_httpbin_put.yaml | 37 +++++++++++ 11 files changed, 453 insertions(+), 32 deletions(-) create mode 100644 tests/unit/test_base_vcr_json.py create mode 100644 tests/unit/test_base_vcr_yaml.py create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_delete.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_get.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_post.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_put.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_delete.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_get.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_post.yaml create mode 100644 tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_put.yaml diff --git a/pyatlan/test_utils/base_vcr.py b/pyatlan/test_utils/base_vcr.py index 4885c5ca2..1930b346b 100644 --- a/pyatlan/test_utils/base_vcr.py +++ b/pyatlan/test_utils/base_vcr.py @@ -204,26 +204,6 @@ def deserialize(cassette_string: str) -> dict: return cassette_dict -class VCRRemoveAllHeaders: - """ - A class responsible for removing all headers from requests and responses. - This can be useful for scenarios where headers are not needed for matching or comparison - in VCR (Virtual Cassette Recorder) interactions, such as when recording or replaying HTTP requests. - """ - - @staticmethod - def remove_all_request_headers(request): - # Save only what's necessary for matching - request.headers = {} - return request - - @staticmethod - def remove_all_response_headers(response): - # Save only what's necessary for matching - response["headers"] = {} - return response - - class BaseVCR: """ A base class for configuring VCR (Virtual Cassette Recorder) @@ -234,7 +214,37 @@ class BaseVCR: It also handles cassette directory configuration. """ + class VCRRemoveAllHeaders: + """ + A class responsible for removing all headers from requests and responses. + This can be useful for scenarios where headers are not needed for matching or comparison + in VCR (Virtual Cassette Recorder) interactions, such as when recording or replaying HTTP requests. + """ + + @staticmethod + def remove_all_request_headers(request): + # Save only what's necessary for matching + request.headers = {} + return request + + @staticmethod + def remove_all_response_headers(response): + # Save only what's necessary for matching + response["headers"] = {} + return response + _CASSETTES_DIR = None + _BASE_CONFIG = { + # More config options can be found at: + # https://vcrpy.readthedocs.io/en/latest/configuration.html#configuration + "record_mode": "once", # (default: "once", "always", "none", "new_episodes") + "serializer": "pretty-yaml", # (default: "yaml") + "decode_compressed_response": True, # Decode compressed responses + # (optional) Replace the Authorization request header with "**REDACTED**" in cassettes + # "filter_headers": [("authorization", "**REDACTED**")], + "before_record_request": VCRRemoveAllHeaders.remove_all_request_headers, + "before_record_response": VCRRemoveAllHeaders.remove_all_response_headers, + } @pytest.fixture(scope="module") def vcr(self, vcr): @@ -263,17 +273,7 @@ def vcr_config(self): :returns: a dictionary with VCR configuration options """ - return { - # More config options can be found at: - # https://vcrpy.readthedocs.io/en/latest/configuration.html#configuration - "record_mode": "once", # (default: "once", "always", "none", "new_episodes") - "serializer": "pretty-yaml", # (default: "yaml") - "decode_compressed_response": True, # Decode compressed responses - # (optional) Replace the Authorization request header with "**REDACTED**" in cassettes - # "filter_headers": [("authorization", "**REDACTED**")], - "before_record_request": VCRRemoveAllHeaders.remove_all_request_headers, - "before_record_response": VCRRemoveAllHeaders.remove_all_response_headers, - } + return self._BASE_CONFIG @pytest.fixture(scope="module") def vcr_cassette_dir(self, request): @@ -290,5 +290,5 @@ def vcr_cassette_dir(self, request): """ # Set self._CASSETTES_DIR or use the default directory path based on the test module name return self._CASSETTES_DIR or os.path.join( - "tests/cassettes", request.module.__name__ + "tests/vcr_cassettes", request.module.__name__ ) diff --git a/tests/unit/test_base_vcr_json.py b/tests/unit/test_base_vcr_json.py new file mode 100644 index 000000000..32f79a673 --- /dev/null +++ b/tests/unit/test_base_vcr_json.py @@ -0,0 +1,65 @@ +import pytest +import requests + +from pyatlan.test_utils.base_vcr import BaseVCR + + +class TestBaseVCRJSON(BaseVCR): + """ + Integration tests to demonstrate VCR.py capabilities + by recording and replaying HTTP interactions using + HTTPBin (https://httpbin.org) for GET, POST, PUT, and DELETE requests. + """ + + BASE_URL = "https://httpbin.org" + + @pytest.fixture(scope="session") + def vcr_config(self): + """ + Override the VCR configuration to use JSON serialization across the module. + """ + config = self._BASE_CONFIG.copy() + config.update({"serializer": "pretty-json"}) + return config + + @pytest.mark.vcr() + def test_httpbin_get(self): + """ + Test a simple GET request to httpbin. + """ + url = f"{self.BASE_URL}/get" + response = requests.get(url, params={"test": "value"}) + assert response.status_code == 200 + assert response.json()["args"]["test"] == "value" + + @pytest.mark.vcr() + def test_httpbin_post(self): + """ + Test a simple POST request to httpbin. + """ + url = f"{self.BASE_URL}/post" + payload = {"name": "atlan", "type": "integration-test"} + response = requests.post(url, json=payload) + assert response.status_code == 200 + assert response.json()["json"] == payload + + @pytest.mark.vcr() + def test_httpbin_put(self): + """ + Test a simple PUT request to httpbin. + """ + url = f"{self.BASE_URL}/put" + payload = {"update": "value"} + response = requests.put(url, json=payload) + assert response.status_code == 200 + assert response.json()["json"] == payload + + @pytest.mark.vcr() + def test_httpbin_delete(self): + """ + Test a simple DELETE request to httpbin. + """ + url = f"{self.BASE_URL}/delete" + response = requests.delete(url) + assert response.status_code == 200 + assert response.json()["args"] == {} diff --git a/tests/unit/test_base_vcr_yaml.py b/tests/unit/test_base_vcr_yaml.py new file mode 100644 index 000000000..107f9f351 --- /dev/null +++ b/tests/unit/test_base_vcr_yaml.py @@ -0,0 +1,61 @@ +import pytest +import requests + +from pyatlan.test_utils.base_vcr import BaseVCR + + +class TestBaseVCRYAML(BaseVCR): + """ + Integration tests to demonstrate VCR.py capabilities + by recording and replaying HTTP interactions using + HTTPBin (https://httpbin.org) for GET, POST, PUT, and DELETE requests. + """ + + BASE_URL = "https://httpbin.org" + + @pytest.mark.vcr() + def test_httpbin_get(self): + """ + Test a simple GET request to httpbin. + """ + url = f"{self.BASE_URL}/get" + response = requests.get(url, params={"test": "value"}) + + assert response.status_code == 200 + assert response.json()["args"]["test"] == "value" + + @pytest.mark.vcr() + def test_httpbin_post(self): + """ + Test a simple POST request to httpbin. + """ + url = f"{self.BASE_URL}/post" + payload = {"name": "atlan", "type": "integration-test"} + response = requests.post(url, json=payload) + + assert response.status_code == 200 + assert response.json()["json"] == payload + + @pytest.mark.vcr() + def test_httpbin_put(self): + """ + Test a simple PUT request to httpbin. + """ + url = f"{self.BASE_URL}/put" + payload = {"update": "value"} + response = requests.put(url, json=payload) + + assert response.status_code == 200 + assert response.json()["json"] == payload + + @pytest.mark.vcr() + def test_httpbin_delete(self): + """ + Test a simple DELETE request to httpbin. + """ + url = f"{self.BASE_URL}/delete" + response = requests.delete(url) + + assert response.status_code == 200 + # HTTPBin returns an empty JSON object for DELETE + assert response.json()["args"] == {} diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_delete.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_delete.yaml new file mode 100644 index 000000000..bd2cb4d96 --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_delete.yaml @@ -0,0 +1,39 @@ +{ + "version": 1, + "interactions": [ + { + "request": { + "method": "DELETE", + "uri": "https://httpbin.org/delete", + "body": null, + "headers": {} + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": {}, + "body": { + "parsed_json": { + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "0", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f7263-439ec4f97dc37ffe07c63697" + }, + "json": null, + "origin": "x.x.x.x", + "url": "https://httpbin.org/delete" + } + } + } + } + ] +} diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_get.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_get.yaml new file mode 100644 index 000000000..ed2c14dcf --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_get.yaml @@ -0,0 +1,36 @@ +{ + "version": 1, + "interactions": [ + { + "request": { + "method": "GET", + "uri": "https://httpbin.org/get?test=value", + "body": null, + "headers": {} + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": {}, + "body": { + "parsed_json": { + "args": { + "test": "value" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f7259-4e5c927e25a0aa78202e04e1" + }, + "origin": "x.x.x.x", + "url": "https://httpbin.org/get?test=value" + } + } + } + } + ] +} diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_post.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_post.yaml new file mode 100644 index 000000000..2d245a440 --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_post.yaml @@ -0,0 +1,43 @@ +{ + "version": 1, + "interactions": [ + { + "request": { + "method": "POST", + "uri": "https://httpbin.org/post", + "body": "{\"name\": \"atlan\", \"type\": \"integration-test\"}", + "headers": {} + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": {}, + "body": { + "parsed_json": { + "args": {}, + "data": "{\"name\": \"atlan\", \"type\": \"integration-test\"}", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "45", + "Content-Type": "application/json", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f725c-53119e7d4121a80069c14836" + }, + "json": { + "name": "atlan", + "type": "integration-test" + }, + "origin": "x.x.x.x", + "url": "https://httpbin.org/post" + } + } + } + } + ] +} diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_put.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_put.yaml new file mode 100644 index 000000000..a5872a1b6 --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_json/TestBaseVCRJSON.test_httpbin_put.yaml @@ -0,0 +1,42 @@ +{ + "version": 1, + "interactions": [ + { + "request": { + "method": "PUT", + "uri": "https://httpbin.org/put", + "body": "{\"update\": \"value\"}", + "headers": {} + }, + "response": { + "status": { + "code": 200, + "message": "OK" + }, + "headers": {}, + "body": { + "parsed_json": { + "args": {}, + "data": "{\"update\": \"value\"}", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "19", + "Content-Type": "application/json", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f7261-631f6aae6a8ae85365354c87" + }, + "json": { + "update": "value" + }, + "origin": "x.x.x.x", + "url": "https://httpbin.org/put" + } + } + } + } + ] +} diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_delete.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_delete.yaml new file mode 100644 index 000000000..7fccbf28b --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_delete.yaml @@ -0,0 +1,31 @@ +interactions: +- request: + body: null + headers: {} + method: DELETE + uri: https://httpbin.org/delete + response: + body: + string: |- + { + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "0", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f7294-550da7df2956737310f29c0c" + }, + "json": null, + "origin": "x.x.x.x", + "url": "https://httpbin.org/delete" + } + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_get.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_get.yaml new file mode 100644 index 000000000..e4e5b86f1 --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_get.yaml @@ -0,0 +1,28 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://httpbin.org/get?test=value + response: + body: + string: |- + { + "args": { + "test": "value" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f728f-7e7ba4866bd0c4c847de2cc2" + }, + "origin": "x.x.x.x", + "url": "https://httpbin.org/get?test=value" + } + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_post.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_post.yaml new file mode 100644 index 000000000..3ab6ee8b7 --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_post.yaml @@ -0,0 +1,39 @@ +interactions: +- request: + body: |- + { + "name": "atlan", + "type": "integration-test" + } + headers: {} + method: POST + uri: https://httpbin.org/post + response: + body: + string: |- + { + "args": {}, + "data": "{\"name\": \"atlan\", \"type\": \"integration-test\"}", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "45", + "Content-Type": "application/json", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f7290-276efa7f015f83d24d9fdfc4" + }, + "json": { + "name": "atlan", + "type": "integration-test" + }, + "origin": "x.x.x.x", + "url": "https://httpbin.org/post" + } + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_put.yaml b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_put.yaml new file mode 100644 index 000000000..ec1bdd256 --- /dev/null +++ b/tests/vcr_cassettes/tests.unit.test_base_vcr_yaml/TestBaseVCRYAML.test_httpbin_put.yaml @@ -0,0 +1,37 @@ +interactions: +- request: + body: |- + { + "update": "value" + } + headers: {} + method: PUT + uri: https://httpbin.org/put + response: + body: + string: |- + { + "args": {}, + "data": "{\"update\": \"value\"}", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "19", + "Content-Type": "application/json", + "Host": "httpbin.org", + "User-Agent": "python-requests/2.32.3", + "X-Amzn-Trace-Id": "Root=1-680f7292-14a3d32d1869399c2db8f571" + }, + "json": { + "update": "value" + }, + "origin": "x.x.x.x", + "url": "https://httpbin.org/put" + } + headers: {} + status: + code: 200 + message: OK +version: 1 From 0119e7719c490d30e6d6a1f055b477ce05362819 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Mon, 28 Apr 2025 18:03:34 +0530 Subject: [PATCH 5/7] [ci/deps] Added `setuptools` to install dependencies step --- .github/workflows/pyatlan-pr.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pyatlan-pr.yaml b/.github/workflows/pyatlan-pr.yaml index b77cf1207..9d916e62f 100644 --- a/.github/workflows/pyatlan-pr.yaml +++ b/.github/workflows/pyatlan-pr.yaml @@ -56,7 +56,7 @@ jobs: - name: Install dependencies run: | - python -m pip install --no-cache-dir --upgrade pip + python -m pip install --no-cache-dir --upgrade pip setuptools if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install --no-cache-dir -r requirements-dev.txt; fi @@ -102,7 +102,7 @@ jobs: - name: Install dependencies run: | - python -m pip install --no-cache-dir --upgrade pip + python -m pip install --no-cache-dir --upgrade pip setuptools if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install --no-cache-dir -r requirements-dev.txt; fi From 50321a7106ef3f23fff1355e499b3af5a56d392f Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Mon, 28 Apr 2025 18:15:42 +0530 Subject: [PATCH 6/7] [temp] testing --- .github/workflows/pyatlan-pr.yaml | 2 + pyatlan/test_utils/base_vcr.py | 22 +++--- requirements-dev.txt | 3 +- test_vcr.py | 107 ++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 test_vcr.py diff --git a/.github/workflows/pyatlan-pr.yaml b/.github/workflows/pyatlan-pr.yaml index 9d916e62f..c231e9732 100644 --- a/.github/workflows/pyatlan-pr.yaml +++ b/.github/workflows/pyatlan-pr.yaml @@ -59,6 +59,7 @@ jobs: python -m pip install --no-cache-dir --upgrade pip setuptools if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install --no-cache-dir -r requirements-dev.txt; fi + pip list - name: QA checks (ruff-format, ruff-lint, mypy) run: | @@ -105,6 +106,7 @@ jobs: python -m pip install --no-cache-dir --upgrade pip setuptools if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install --no-cache-dir -r requirements-dev.txt; fi + pip list - name: Run integration tests env: # Test tenant environment variables diff --git a/pyatlan/test_utils/base_vcr.py b/pyatlan/test_utils/base_vcr.py index 1930b346b..1279004de 100644 --- a/pyatlan/test_utils/base_vcr.py +++ b/pyatlan/test_utils/base_vcr.py @@ -13,17 +13,17 @@ "pytest-vcr plugin is not installed. Please install pytest-vcr." ) -# Check if vcrpy is installed and ensure the version is 6.0.x -try: - vcr_version = pkg_resources.get_distribution("vcrpy").version - if not vcr_version.startswith("6.0"): - raise DependencyNotFoundError( - f"vcrpy version 6.0.x is required, but found {vcr_version}. Please install the correct version." - ) -except pkg_resources.DistributionNotFound: - raise DependencyNotFoundError( - "vcrpy version 6.0.x is not installed. Please install vcrpy version 6.0.x." - ) +# # Check if vcrpy is installed and ensure the version is 6.0.x +# try: +# vcr_version = pkg_resources.get_distribution("vcrpy").version +# if not vcr_version.startswith("6.0"): +# raise DependencyNotFoundError( +# f"vcrpy version 6.0.x is required, but found {vcr_version}. Please install the correct version." +# ) +# except pkg_resources.DistributionNotFound: +# raise DependencyNotFoundError( +# "vcrpy version 6.0.x is not installed. Please install vcrpy version 6.0.x." +# ) import json import os diff --git a/requirements-dev.txt b/requirements-dev.txt index 221a00d69..dcc64cdfa 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,11 +3,12 @@ ruff~=0.9.9 # [PINNED] for Python 3.8 compatibility # higher versions require urllib3>=2.0 types-requests~=2.31.0.6 +types-setuptools~=75.8.0.20250110 pytest~=8.3.4 pytest-vcr~=1.0.2 # [PINNED] to v6.x since vcrpy>=7.0 requires urllib3>=2.0 # which breaks compatibility with Python 3.8 -vcrpy~=6.0.2 +# vcrpy~=6.0.2 pytest-order~=1.3.0 pytest-timer[termcolor]~=1.0.0 pytest-sugar~=1.0.0 diff --git a/test_vcr.py b/test_vcr.py new file mode 100644 index 000000000..b820783dc --- /dev/null +++ b/test_vcr.py @@ -0,0 +1,107 @@ +import logging +from typing import Callable, List, Optional + +import pytest +import requests + +from pyatlan.client.atlan import AtlanClient +from pyatlan.model.assets import Asset, Connection +from pyatlan.model.enums import AtlanConnectorType +from pyatlan.model.response import AssetMutationResponse +from pyatlan.test_utils import TestId +from pyatlan.test_utils.base_vcr import BaseVCR + +LOGGER = logging.getLogger(__name__) + + +class TestBaseVCR(BaseVCR): + @pytest.mark.vcr() + def test_sample_get(self): + response = requests.get("https://www.google.com/") + assert response.status_code == 201 + + +class TestConnection(BaseVCR): + connection: Optional[Connection] = None + + @pytest.fixture(scope="module") + def client(self) -> AtlanClient: + return AtlanClient() + + @pytest.fixture(scope="module") + def upsert(self, client: AtlanClient): + guids: List[str] = [] + + def _upsert(asset: Asset) -> AssetMutationResponse: + _response = client.asset.save(asset) + if ( + _response + and _response.mutated_entities + and _response.mutated_entities.CREATE + ): + guids.append(_response.mutated_entities.CREATE[0].guid) + return _response + + yield _upsert + + # for guid in reversed(guids): + # response = client.asset.purge_by_guid(guid) + # if ( + # not response + # or not response.mutated_entities + # or not response.mutated_entities.DELETE + # ): + # LOGGER.error(f"Failed to remove asset with GUID {guid}.") + + @pytest.mark.vcr(cassette_name="TestConnectionCreate.json") + def test_create( + self, + client: AtlanClient, + upsert: Callable[[Asset], AssetMutationResponse], + ): + role = client.role_cache.get_id_for_name("$admin") + assert role + connection_name = TestId.make_unique("INT") + c = Connection.create( + name=connection_name, + connector_type=AtlanConnectorType.SNOWFLAKE, + admin_roles=[role], + ) + assert c.guid + response = upsert(c) + assert response.mutated_entities + assert response.mutated_entities.CREATE + assert len(response.mutated_entities.CREATE) == 1 + assert isinstance(response.mutated_entities.CREATE[0], Connection) + assert response.guid_assignments + c = response.mutated_entities.CREATE[0] + c = client.asset.get_by_guid(c.guid, Connection, ignore_relationships=False) + assert isinstance(c, Connection) + TestConnection.connection = c + + # @pytest.mark.order(after="test_create") + # @pytest.mark.vcr() + # def test_create_for_modification( + # self, client: AtlanClient, upsert: Callable[[Asset], AssetMutationResponse] + # ): + # assert TestConnection.connection + # assert TestConnection.connection.name + # connection = TestConnection.connection + # description = f"{connection.description} more stuff" + # connection = Connection.create_for_modification( + # qualified_name=TestConnection.connection.qualified_name or "", + # name=TestConnection.connection.name, + # ) + # connection.description = description + # response = upsert(connection) + # verify_asset_updated(response, Connection) + + # @pytest.mark.order(after="test_create") + # @pytest.mark.vcr() + # def test_trim_to_required( + # self, client: AtlanClient, upsert: Callable[[Asset], AssetMutationResponse] + # ): + # assert TestConnection.connection + # connection = TestConnection.connection.trim_to_required() + # response = upsert(connection) + # assert response.mutated_entities is None From a13768f16d38a29f07416bf40407c6f2cde3b3a5 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Mon, 28 Apr 2025 19:21:26 +0530 Subject: [PATCH 7/7] [test/fix] Implemented a fix for `VCRHTTPResponse.version_string` https://github.com/kevin1024/vcrpy/issues/888 --- .github/workflows/pyatlan-pr.yaml | 2 - pyatlan/test_utils/base_vcr.py | 22 +++--- requirements-dev.txt | 2 +- test_vcr.py | 107 ------------------------------ tests/unit/conftest.py | 16 +++++ 5 files changed, 28 insertions(+), 121 deletions(-) delete mode 100644 test_vcr.py diff --git a/.github/workflows/pyatlan-pr.yaml b/.github/workflows/pyatlan-pr.yaml index c231e9732..9d916e62f 100644 --- a/.github/workflows/pyatlan-pr.yaml +++ b/.github/workflows/pyatlan-pr.yaml @@ -59,7 +59,6 @@ jobs: python -m pip install --no-cache-dir --upgrade pip setuptools if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install --no-cache-dir -r requirements-dev.txt; fi - pip list - name: QA checks (ruff-format, ruff-lint, mypy) run: | @@ -106,7 +105,6 @@ jobs: python -m pip install --no-cache-dir --upgrade pip setuptools if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install --no-cache-dir -r requirements-dev.txt; fi - pip list - name: Run integration tests env: # Test tenant environment variables diff --git a/pyatlan/test_utils/base_vcr.py b/pyatlan/test_utils/base_vcr.py index 1279004de..1930b346b 100644 --- a/pyatlan/test_utils/base_vcr.py +++ b/pyatlan/test_utils/base_vcr.py @@ -13,17 +13,17 @@ "pytest-vcr plugin is not installed. Please install pytest-vcr." ) -# # Check if vcrpy is installed and ensure the version is 6.0.x -# try: -# vcr_version = pkg_resources.get_distribution("vcrpy").version -# if not vcr_version.startswith("6.0"): -# raise DependencyNotFoundError( -# f"vcrpy version 6.0.x is required, but found {vcr_version}. Please install the correct version." -# ) -# except pkg_resources.DistributionNotFound: -# raise DependencyNotFoundError( -# "vcrpy version 6.0.x is not installed. Please install vcrpy version 6.0.x." -# ) +# Check if vcrpy is installed and ensure the version is 6.0.x +try: + vcr_version = pkg_resources.get_distribution("vcrpy").version + if not vcr_version.startswith("6.0"): + raise DependencyNotFoundError( + f"vcrpy version 6.0.x is required, but found {vcr_version}. Please install the correct version." + ) +except pkg_resources.DistributionNotFound: + raise DependencyNotFoundError( + "vcrpy version 6.0.x is not installed. Please install vcrpy version 6.0.x." + ) import json import os diff --git a/requirements-dev.txt b/requirements-dev.txt index dcc64cdfa..3d25f0c2c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ pytest~=8.3.4 pytest-vcr~=1.0.2 # [PINNED] to v6.x since vcrpy>=7.0 requires urllib3>=2.0 # which breaks compatibility with Python 3.8 -# vcrpy~=6.0.2 +vcrpy~=6.0.2 pytest-order~=1.3.0 pytest-timer[termcolor]~=1.0.0 pytest-sugar~=1.0.0 diff --git a/test_vcr.py b/test_vcr.py deleted file mode 100644 index b820783dc..000000000 --- a/test_vcr.py +++ /dev/null @@ -1,107 +0,0 @@ -import logging -from typing import Callable, List, Optional - -import pytest -import requests - -from pyatlan.client.atlan import AtlanClient -from pyatlan.model.assets import Asset, Connection -from pyatlan.model.enums import AtlanConnectorType -from pyatlan.model.response import AssetMutationResponse -from pyatlan.test_utils import TestId -from pyatlan.test_utils.base_vcr import BaseVCR - -LOGGER = logging.getLogger(__name__) - - -class TestBaseVCR(BaseVCR): - @pytest.mark.vcr() - def test_sample_get(self): - response = requests.get("https://www.google.com/") - assert response.status_code == 201 - - -class TestConnection(BaseVCR): - connection: Optional[Connection] = None - - @pytest.fixture(scope="module") - def client(self) -> AtlanClient: - return AtlanClient() - - @pytest.fixture(scope="module") - def upsert(self, client: AtlanClient): - guids: List[str] = [] - - def _upsert(asset: Asset) -> AssetMutationResponse: - _response = client.asset.save(asset) - if ( - _response - and _response.mutated_entities - and _response.mutated_entities.CREATE - ): - guids.append(_response.mutated_entities.CREATE[0].guid) - return _response - - yield _upsert - - # for guid in reversed(guids): - # response = client.asset.purge_by_guid(guid) - # if ( - # not response - # or not response.mutated_entities - # or not response.mutated_entities.DELETE - # ): - # LOGGER.error(f"Failed to remove asset with GUID {guid}.") - - @pytest.mark.vcr(cassette_name="TestConnectionCreate.json") - def test_create( - self, - client: AtlanClient, - upsert: Callable[[Asset], AssetMutationResponse], - ): - role = client.role_cache.get_id_for_name("$admin") - assert role - connection_name = TestId.make_unique("INT") - c = Connection.create( - name=connection_name, - connector_type=AtlanConnectorType.SNOWFLAKE, - admin_roles=[role], - ) - assert c.guid - response = upsert(c) - assert response.mutated_entities - assert response.mutated_entities.CREATE - assert len(response.mutated_entities.CREATE) == 1 - assert isinstance(response.mutated_entities.CREATE[0], Connection) - assert response.guid_assignments - c = response.mutated_entities.CREATE[0] - c = client.asset.get_by_guid(c.guid, Connection, ignore_relationships=False) - assert isinstance(c, Connection) - TestConnection.connection = c - - # @pytest.mark.order(after="test_create") - # @pytest.mark.vcr() - # def test_create_for_modification( - # self, client: AtlanClient, upsert: Callable[[Asset], AssetMutationResponse] - # ): - # assert TestConnection.connection - # assert TestConnection.connection.name - # connection = TestConnection.connection - # description = f"{connection.description} more stuff" - # connection = Connection.create_for_modification( - # qualified_name=TestConnection.connection.qualified_name or "", - # name=TestConnection.connection.name, - # ) - # connection.description = description - # response = upsert(connection) - # verify_asset_updated(response, Connection) - - # @pytest.mark.order(after="test_create") - # @pytest.mark.vcr() - # def test_trim_to_required( - # self, client: AtlanClient, upsert: Callable[[Asset], AssetMutationResponse] - # ): - # assert TestConnection.connection - # connection = TestConnection.connection.trim_to_required() - # response = upsert(connection) - # assert response.mutated_entities is None diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a4a71b48e..ee8142e0e 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -31,3 +31,19 @@ def mock_custom_metadata_cache(): def mock_tag_cache(): with patch("pyatlan.cache.atlan_tag_cache.AtlanTagCache") as cache: yield cache + + +@pytest.fixture(autouse=True) +def patch_vcr_http_response_version_string(): + """ + Patch the VCRHTTPResponse class to add a version_string attribute if it doesn't exist. + + This patch is necessary to avoid bumping vcrpy to 7.0.0, + which drops support for Python 3.8. + """ + from vcr.stubs import VCRHTTPResponse # type: ignore[import-untyped] + + if not hasattr(VCRHTTPResponse, "version_string"): + VCRHTTPResponse.version_string = None + + yield