Repository: PrefectHQ/prefect

Issue:
Migrate `prefect-bitbucket` to core



Discussion:

Resolving patch:
diff --git a/src/integrations/prefect-bitbucket/prefect_bitbucket/__init__.py b/src/integrations/prefect-bitbucket/prefect_bitbucket/__init__.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/prefect_bitbucket/__init__.py
@@ -0,0 +1,8 @@
+"""Contains the credentials and repository submodules."""
+
+from . import _version
+from .credentials import BitBucketCredentials  # noqa
+from .repository import BitBucketRepository  # noqa
+
+__version__ = _version.__version__
+__all__ = ["BitBucketCredentials", "BitBucketRepository"]
diff --git a/src/integrations/prefect-bitbucket/prefect_bitbucket/credentials.py b/src/integrations/prefect-bitbucket/prefect_bitbucket/credentials.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/prefect_bitbucket/credentials.py
@@ -0,0 +1,111 @@
+"""Module to enable authenticate interactions with BitBucket."""
+import re
+from enum import Enum
+from typing import Optional, Union
+
+from pydantic import VERSION as PYDANTIC_VERSION
+
+from prefect.blocks.abstract import CredentialsBlock
+
+if PYDANTIC_VERSION.startswith("2."):
+    from pydantic.v1 import Field, SecretStr, validator
+else:
+    from pydantic import Field, SecretStr, validator
+
+try:
+    from atlassian.bitbucket import Bitbucket, Cloud
+except ImportError:
+    pass
+
+
+class ClientType(Enum):
+    """The client type to use."""
+
+    LOCAL = "local"
+    CLOUD = "cloud"
+
+
+class BitBucketCredentials(CredentialsBlock):
+    """Store BitBucket credentials to interact with private BitBucket repositories.
+
+    Attributes:
+        token: An access token to authenticate with BitBucket. This is required
+            for accessing private repositories.
+        username: Identification name unique across entire BitBucket site.
+        password: The password to authenticate to BitBucket.
+        url: The base URL of your BitBucket instance.
+
+
+    Examples:
+        Load stored BitBucket credentials:
+        ```python
+        from prefect_bitbucket import BitBucketCredentials
+        bitbucket_credentials_block = BitBucketCredentials.load("BLOCK_NAME")
+        ```
+
+
+    """
+
+    _block_type_name = "BitBucket Credentials"
+    _logo_url = "https://cdn.sanity.io/images/3ugk85nk/production/5d729f7355fb6828c4b605268ded9cfafab3ae4f-250x250.png"  # noqa
+    token: Optional[SecretStr] = Field(
+        name="Personal Access Token",
+        default=None,
+        description=(
+            "A BitBucket Personal Access Token - required for private repositories."
+        ),
+        example="x-token-auth:my-token",
+    )
+    username: Optional[str] = Field(
+        default=None,
+        description="Identification name unique across entire BitBucket site.",
+    )
+    password: Optional[SecretStr] = Field(
+        default=None, description="The password to authenticate to BitBucket."
+    )
+    url: str = Field(
+        default="https://api.bitbucket.org/",
+        description="The base URL of your BitBucket instance.",
+        title="URL",
+    )
+
+    @validator("username")
+    def _validate_username(cls, value: str) -> str:
+        """When username provided, will validate it."""
+        pattern = "^[A-Za-z0-9_-]*$"
+
+        if not re.match(pattern, value):
+            raise ValueError(
+                "Username must be alpha, num, dash and/or underscore only."
+            )
+        if not len(value) <= 30:
+            raise ValueError("Username cannot be longer than 30 chars.")
+        return value
+
+    def get_client(
+        self, client_type: Union[str, ClientType], **client_kwargs
+    ) -> Union[Cloud, Bitbucket]:
+        """Get an authenticated local or cloud Bitbucket client.
+
+        Args:
+            client_type: Whether to use a local or cloud client.
+
+        Returns:
+            An authenticated Bitbucket client.
+
+        """
+        # ref: https://atlassian-python-api.readthedocs.io/
+        if isinstance(client_type, str):
+            client_type = ClientType(client_type.lower())
+
+        password = self.password.get_secret_value()
+        input_client_kwargs = dict(
+            url=self.url, username=self.username, password=password
+        )
+        input_client_kwargs.update(**client_kwargs)
+
+        if client_type == ClientType.CLOUD:
+            client = Cloud(**input_client_kwargs)
+        else:
+            client = Bitbucket(**input_client_kwargs)
+        return client
diff --git a/src/integrations/prefect-bitbucket/prefect_bitbucket/repository.py b/src/integrations/prefect-bitbucket/prefect_bitbucket/repository.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/prefect_bitbucket/repository.py
@@ -0,0 +1,200 @@
+"""Allows for interaction with a BitBucket repository.
+
+The `BitBucket` class in this collection is a storage block that lets Prefect agents
+pull Prefect flow code from BitBucket repositories.
+
+The `BitBucket` block is ideally configured via the Prefect UI, but can also be used
+in Python as the following examples demonstrate.
+
+Examples
+```python
+from prefect_bitbucket.repository import BitBucketRepository
+
+# public BitBucket repository
+public_bitbucket_block = BitBucketRepository(
+    repository="https://bitbucket.com/my-project/my-repository.git"
+)
+
+public_bitbucket_block.save(name="my-bitbucket-block")
+
+# specific branch or tag
+branch_bitbucket_block = BitBucketRepository(
+    reference="branch-or-tag-name",
+    repository="https://bitbucket.com/my-project/my-repository.git"
+)
+
+branch_bitbucket_block.save(name="my-bitbucket-block")
+
+# private BitBucket repository
+private_bitbucket_block = BitBucketRepository(
+    repository="https://bitbucket.com/my-project/my-repository.git",
+    bitbucket_credentials=BitBucketCredentials.load("my-bitbucket-credentials-block")
+)
+
+private_bitbucket_block.save(name="my-private-bitbucket-block")
+
+"""
+
+import io
+from distutils.dir_util import copy_tree
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Optional, Tuple, Union
+from urllib.parse import urlparse, urlunparse
+
+from pydantic import VERSION as PYDANTIC_VERSION
+
+from prefect.exceptions import InvalidRepositoryURLError
+from prefect.filesystems import ReadableDeploymentStorage
+from prefect.utilities.asyncutils import sync_compatible
+from prefect.utilities.processutils import run_process
+
+if PYDANTIC_VERSION.startswith("2."):
+    from pydantic.v1 import Field, validator
+else:
+    from pydantic import Field, validator
+
+from prefect_bitbucket.credentials import BitBucketCredentials
+
+
+class BitBucketRepository(ReadableDeploymentStorage):
+    """Interact with files stored in BitBucket repositories.
+
+    An accessible installation of git is required for this block to function
+    properly.
+    """
+
+    _block_type_name = "BitBucket Repository"
+    _logo_url = "https://cdn.sanity.io/images/3ugk85nk/production/5d729f7355fb6828c4b605268ded9cfafab3ae4f-250x250.png"  # noqa
+    _description = "Interact with files stored in BitBucket repositories."
+
+    repository: str = Field(
+        default=...,
+        description="The URL of a BitBucket repository to read from in HTTPS format",
+    )
+    reference: Optional[str] = Field(
+        default=None,
+        description="An optional reference to pin to; can be a branch or tag.",
+    )
+    bitbucket_credentials: Optional[BitBucketCredentials] = Field(
+        default=None,
+        description=(
+            "An optional BitBucketCredentials block for authenticating with "
+            "private BitBucket repos."
+        ),
+    )
+
+    @validator("bitbucket_credentials")
+    def _ensure_credentials_go_with_https(cls, v: str, values: dict) -> str:
+        """Ensure that credentials are not provided with 'SSH' formatted BitBucket URLs.
+
+        Validators are by default only called on provided arguments.
+
+        Note: validates `credentials` specifically so that it only fires when private
+        repositories are used.
+        """
+        if v is not None:
+            if urlparse(values["repository"]).scheme != "https":
+                raise InvalidRepositoryURLError(
+                    (
+                        "Credentials can only be used with BitBucket repositories "
+                        "using the 'HTTPS' format. You must either remove the "
+                        "credential if you wish to use the 'SSH' format and are not "
+                        "using a private repository, or you must change the repository "
+                        "URL to the 'HTTPS' format."
+                    )
+                )
+
+        return v
+
+    def _create_repo_url(self) -> str:
+        """Format the URL provided to the `git clone` command.
+
+        For private repos in the cloud:
+        https://x-token-auth:<access-token>@bitbucket.org/<user>/<repo>.git
+        For private repos with a local bitbucket server:
+        https://<username>:<access-token>@<server>/scm/<project>/<repo>.git
+
+        All other repos should be the same as `self.repository`.
+        """
+        url_components = urlparse(self.repository)
+        token_is_set = (
+            self.bitbucket_credentials is not None and self.bitbucket_credentials.token
+        )
+
+        # Need a token for private repos
+        if url_components.scheme == "https" and token_is_set:
+            token = self.bitbucket_credentials.token.get_secret_value()
+            username = self.bitbucket_credentials.username
+            if username is None:
+                username = "x-token-auth"
+            updated_components = url_components._replace(
+                netloc=f"{username}:{token}@{url_components.netloc}"
+            )
+            full_url = urlunparse(updated_components)
+        else:
+            full_url = self.repository
+
+        return full_url
+
+    @staticmethod
+    def _get_paths(
+        dst_dir: Union[str, None], src_dir: str, sub_directory: Optional[str]
+    ) -> Tuple[str, str]:
+        """Return the fully formed paths for BitBucketRepository contents.
+
+        Return will take the form of (content_source, content_destination).
+
+        """
+        if dst_dir is None:
+            content_destination = Path(".").absolute()
+        else:
+            content_destination = Path(dst_dir)
+
+        content_source = Path(src_dir)
+
+        if sub_directory:
+            content_destination = content_destination.joinpath(sub_directory)
+            content_source = content_source.joinpath(sub_directory)
+
+        return str(content_source), str(content_destination)
+
+    @sync_compatible
+    async def get_directory(
+        self, from_path: Optional[str] = None, local_path: Optional[str] = None
+    ) -> None:
+        """Clones a BitBucket project within `from_path` to the provided `local_path`.
+
+        This defaults to cloning the repository reference configured on the
+        Block to the present working directory.
+
+        Args:
+            from_path: If provided, interpreted as a subdirectory of the underlying
+                repository that will be copied to the provided local path.
+            local_path: A local path to clone to; defaults to present working directory.
+
+        """
+        # Construct command
+        cmd = ["git", "clone", self._create_repo_url()]
+        if self.reference:
+            cmd += ["-b", self.reference]
+
+        # Limit git history
+        cmd += ["--depth", "1"]
+
+        # Clone to a temporary directory and move the subdirectory over
+        with TemporaryDirectory(suffix="prefect") as tmp_dir:
+            cmd.append(tmp_dir)
+
+            err_stream = io.StringIO()
+            out_stream = io.StringIO()
+            process = await run_process(cmd, stream_output=(out_stream, err_stream))
+            if process.returncode != 0:
+                err_stream.seek(0)
+                raise OSError(f"Failed to pull from remote:\n {err_stream.read()}")
+
+            content_source, content_destination = self._get_paths(
+                dst_dir=local_path, src_dir=tmp_dir, sub_directory=from_path
+            )
+
+            copy_tree(src=content_source, dst=content_destination)


Test patch:
diff --git a/src/integrations/prefect-bitbucket/tests/conftest.py b/src/integrations/prefect-bitbucket/tests/conftest.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/tests/conftest.py
@@ -0,0 +1,23 @@
+import pytest
+
+from prefect.testing.utilities import prefect_test_harness
+
+
+@pytest.fixture(scope="session", autouse=True)
+def prefect_db():
+    """
+    Sets up test harness for temporary DB during test runs.
+    """
+    with prefect_test_harness():
+        yield
+
+
+@pytest.fixture(autouse=True)
+def reset_object_registry():
+    """
+    Ensures each test has a clean object registry.
+    """
+    from prefect.context import PrefectObjectRegistry
+
+    with PrefectObjectRegistry():
+        yield
diff --git a/src/integrations/prefect-bitbucket/tests/test_credentials.py b/src/integrations/prefect-bitbucket/tests/test_credentials.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/tests/test_credentials.py
@@ -0,0 +1,52 @@
+import pytest
+from atlassian.bitbucket import Bitbucket, Cloud
+from prefect_bitbucket.credentials import BitBucketCredentials, ClientType
+
+from prefect.blocks.core import Block
+
+
+@pytest.mark.parametrize("token", [None, "token_value"])
+def test_bitbucket_credentials(token):
+    """Test credentials is Block type."""
+    credentials_block = BitBucketCredentials(token=token)
+    assert isinstance(credentials_block, Block)
+
+
+def ensure_valid_bitbucket_username_passes():
+    """Ensure invalid char username raises."""
+    try:
+        BitBucketCredentials(token="token", username="validusername")
+    except Exception as exc:
+        assert False, f"Valid username raised an exception {exc}"
+
+
+def test_bitbucket_username_invalid_char():
+    """Ensure invalid char username raises."""
+    with pytest.raises(ValueError):
+        BitBucketCredentials(token="token", username="invalid!username")
+
+
+def test_bitbucket_username_over_max_length():
+    """Ensure username of greater than max allowed length raises."""
+    with pytest.raises(ValueError):
+        BitBucketCredentials(
+            token="token", username="usernamethatisoverthirtycharacters"
+        )
+
+
+@pytest.mark.parametrize(
+    "client_type",
+    ["local", "LOCAL", "cloud", "Cloud", ClientType.LOCAL, ClientType.CLOUD],
+)
+def test_bitbucket_get_client(client_type):
+    bitbucket_credentials = BitBucketCredentials(
+        url="my-url", username="my-username", password="my-password"
+    )
+    client = bitbucket_credentials.get_client(client_type=client_type)
+    if not isinstance(client_type, str):
+        client_type = client_type.value
+
+    if client_type.lower() == "local":
+        assert isinstance(client, Bitbucket)
+    else:
+        assert isinstance(client, Cloud)
diff --git a/src/integrations/prefect-bitbucket/tests/test_repository.py b/src/integrations/prefect-bitbucket/tests/test_repository.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/tests/test_repository.py
@@ -0,0 +1,277 @@
+import os
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Set, Tuple
+
+import pytest
+from pydantic import VERSION as PYDANTIC_VERSION
+
+from prefect.exceptions import InvalidRepositoryURLError
+from prefect.testing.utilities import AsyncMock
+
+if PYDANTIC_VERSION.startswith("2."):
+    from pydantic.v1 import SecretStr
+else:
+    from pydantic import SecretStr
+
+import prefect_bitbucket
+from prefect_bitbucket.credentials import BitBucketCredentials
+from prefect_bitbucket.repository import BitBucketRepository
+
+
+class TestBitBucketRepository:
+    def setup_test_directory(
+        self, tmp_src: str, sub_dir: str = "puppy"
+    ) -> Tuple[Set[str], Set[str]]:
+        """Add files and directories to a temporary directory.
+
+        Returns a tuple with the expected parent-level contents and the
+        expected child-level contents.
+        """
+
+        # add file to tmp_src
+        f1_name = "dog.text"
+        f1_path = Path(tmp_src) / f1_name
+        f1 = open(f1_path, "w")
+        f1.close()
+
+        # add sub-directory to tmp_src
+        sub_dir_path = Path(tmp_src) / sub_dir
+        os.mkdir(sub_dir_path)
+
+        # add file to sub-directory
+        f2_name = "cat.txt"
+        f2_path = sub_dir_path / f2_name
+        f2 = open(f2_path, "w")
+        f2.close()
+
+        parent_contents = {f1_name, sub_dir}
+        child_contents = {f2_name}
+
+        assert set(os.listdir(tmp_src)) == parent_contents
+        assert set(os.listdir(sub_dir_path)) == child_contents
+
+        return parent_contents, child_contents
+
+    class MockTmpDir:
+        """Utility for having `TemporaryDirectory` return a known location."""
+
+        dir = None
+
+        def __init__(self, *args, **kwargs):
+            pass
+
+        def __enter__(self):
+            """Create mock directory and return it."""
+            return self.dir
+
+        def __exit__(self, *args, **kwargs):
+            """Delete mock directory."""
+
+    # Does it raise an OSError or a RuntimeError
+    async def test_subprocess_errors_are_surfaced(self):
+        """Ensure that errors from BitBucket are being surfaced to users."""
+
+        b = BitBucketRepository(repository="incorrect-url-scheme")
+        with pytest.raises(
+            OSError, match="fatal: repository 'incorrect-url-scheme' does not exist"
+        ):
+            await b.get_directory()
+
+    async def test_repository_default(self, monkeypatch):
+        """Ensure that default command is `git clone <repo-name>` when given just a repo.  # noqa"""
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+        b = BitBucketRepository(repository="prefect")
+        await b.get_directory()
+
+        assert mock.await_count == 1
+        expected_cmd = ["git", "clone", "prefect"]
+        assert mock.await_args[0][0][: len(expected_cmd)] == expected_cmd
+
+    async def test_repository_default_with_credentials_none(self, monkeypatch):
+        """Ensure that default command is `git clone <repo-name>` when given just a repo.  # noqa"""
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+        b = BitBucketRepository(repository="prefect", bitbucket_credentials=None)
+        await b.get_directory()
+
+        assert mock.await_count == 1
+        expected_cmd = ["git", "clone", "prefect"]
+        assert mock.await_args[0][0][: len(expected_cmd)] == expected_cmd
+
+    async def test_reference_default(self, monkeypatch):
+        """Ensure that default command is `git clone <repo-name> -b <reference> --depth 1`  # noqa
+        when just a repo and reference are given.
+        """
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+        b = BitBucketRepository(repository="prefect", reference="2.0.0")
+        await b.get_directory()
+
+        assert mock.await_count == 1
+        expected_cmd = ["git", "clone", "prefect", "-b", "2.0.0", "--depth", "1"]
+        assert mock.await_args[0][0][: len(expected_cmd)] == expected_cmd
+
+    async def test_token_added_correctly_from_credential(self, monkeypatch):
+        """Ensure that the repo url is in the format `https://x-token-auth@bitbucket.org/<workspace>/<repo>.git`."""  # noqa: E501
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+        credential = "XYZ"
+        repo = "https://bitbucket.org/PrefectHQ/prefect.git"
+        b = BitBucketRepository(
+            repository=repo,
+            bitbucket_credentials=BitBucketCredentials(token=SecretStr(credential)),
+        )
+        await b.get_directory()
+        assert mock.await_count == 1
+        expected_cmd = [
+            "git",
+            "clone",
+            f"https://x-token-auth:{credential}@bitbucket.org/PrefectHQ/prefect.git",
+            "--depth",
+            "1",
+        ]
+        assert mock.await_args[0][0][: len(expected_cmd)] == expected_cmd
+
+    async def test_token_with_username_added_correctly_from_credential(
+        self, monkeypatch
+    ):
+        """Ensure that the repo url is in the format `https://username:token@url`."""  # noqa: E501
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+        credential = "XYZ"
+        username = "ABC"
+        repo = "https://bitbucket.org/PrefectHQ/prefect.git"
+        b = BitBucketRepository(
+            repository=repo,
+            bitbucket_credentials=BitBucketCredentials(
+                token=SecretStr(credential), username=username
+            ),
+        )
+        await b.get_directory()
+        assert mock.await_count == 1
+        expected_cmd = [
+            "git",
+            "clone",
+            f"https://{username}:{credential}@bitbucket.org/PrefectHQ/prefect.git",
+            "--depth",
+            "1",
+        ]
+        assert mock.await_args[0][0][: len(expected_cmd)] == expected_cmd
+
+    async def test_ssh_fails_with_credential(self, monkeypatch):
+        """Ensure that credentials cannot be passed in if the URL is not in the HTTPS
+        format.
+        """
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+        credential = "XYZ"
+        error_msg = (
+            "Credentials can only be used with BitBucket repositories "
+            "using the 'HTTPS' format. You must either remove the "
+            "credential if you wish to use the 'SSH' format and are not "
+            "using a private repository, or you must change the repository "
+            "URL to the 'HTTPS' format."
+        )
+        with pytest.raises(InvalidRepositoryURLError, match=error_msg):
+            BitBucketRepository(
+                repository="git@bitbucket.org:PrefectHQ/prefect.git",
+                bitbucket_credentials=BitBucketCredentials(token=SecretStr(credential)),
+            )
+
+    async def test_dir_contents_copied_correctly_with_get_directory(self, monkeypatch):  # noqa
+        """Check that `get_directory` is able to correctly copy contents from src->dst"""  # noqa
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+
+        sub_dir_name = "puppy"
+
+        with TemporaryDirectory() as tmp_src:
+            parent_contents, child_contents = self.setup_test_directory(
+                tmp_src, sub_dir_name
+            )
+            self.MockTmpDir.dir = tmp_src
+
+            # move file contents to tmp_dst
+            with TemporaryDirectory() as tmp_dst:
+                monkeypatch.setattr(
+                    prefect_bitbucket.repository,
+                    "TemporaryDirectory",
+                    self.MockTmpDir,
+                )
+
+                b = BitBucketRepository(
+                    repository="https://bitbucket.org/PrefectHQ/prefect.git",
+                )
+                await b.get_directory(local_path=tmp_dst)
+
+                assert set(os.listdir(tmp_dst)) == parent_contents
+                assert set(os.listdir(Path(tmp_dst) / sub_dir_name)) == child_contents
+
+    async def test_dir_contents_copied_correctly_with_get_directory_and_from_path(
+        self, monkeypatch
+    ):  # noqa
+        """Check that `get_directory` is able to correctly copy contents from src->dst
+        when `from_path` is included.
+        It is expected that the directory specified by `from_path` will be moved to the
+        specified destination, along with all of its contents.
+        """
+
+        class p:
+            returncode = 0
+
+        mock = AsyncMock(return_value=p())
+        monkeypatch.setattr(prefect_bitbucket.repository, "run_process", mock)
+
+        sub_dir_name = "puppy"
+
+        with TemporaryDirectory() as tmp_src:
+            parent_contents, child_contents = self.setup_test_directory(
+                tmp_src, sub_dir_name
+            )
+            self.MockTmpDir.dir = tmp_src
+
+            # move file contents to tmp_dst
+            with TemporaryDirectory() as tmp_dst:
+                monkeypatch.setattr(
+                    prefect_bitbucket.repository,
+                    "TemporaryDirectory",
+                    self.MockTmpDir,
+                )
+
+                b = BitBucketRepository(
+                    repository="https://bitbucket.org/PrefectHQ/prefect.git",
+                )
+                await b.get_directory(local_path=tmp_dst, from_path=sub_dir_name)
+
+                assert set(os.listdir(tmp_dst)) == set([sub_dir_name])
+                assert set(os.listdir(Path(tmp_dst) / sub_dir_name)) == child_contents
diff --git a/src/integrations/prefect-bitbucket/tests/test_version.py b/src/integrations/prefect-bitbucket/tests/test_version.py
new file mode 100644
--- /dev/null
+++ b/src/integrations/prefect-bitbucket/tests/test_version.py
@@ -0,0 +1,9 @@
+from packaging.version import Version
+
+
+def test_version():
+    from prefect_bitbucket import __version__
+
+    assert isinstance(__version__, str)
+    assert Version(__version__)
+    assert __version__.startswith("0.")