Repository: atlassian-api/atlassian-python-api

Issue:
Bitbucket Cloud lacks the "project_exists" and "repo_exists" methods available for Server
So I'm running into this issue whereby attempting to create a project that already exists returns a 400 from Bitbucket Cloud. This seems makes sense and seems expected because looking at the [Projects class](https://github.com/atlassian-api/atlassian-python-api/blob/master/atlassian/bitbucket/cloud/workspaces/projects.py), it's using a POST operation for the "create" method.

Here's my code:

```
bitbucket_cloud = Cloud(
    url = 'https://api.bitbucket.org/',
    username=args.cloud_user,
    password=args.cloud_pass,
    cloud=True
)

args.workspace = "a-workspace-name"

bitbucket_cloud.workspaces.get(args.workspace)projects.create(
    key="ABC",
    name="ABC Project",
    description="A useful description",
    is_private=True
)
```

This returns:

```
Traceback (most recent call last):
  File "src/bbmigrate/cli2.py", line 80, in <module>
    bitbucket_cloud.workspaces.get(args.cloud_workspace).projects.create(
  File "/home/mdavis/.local/share/virtualenvs/bb-server-cloud-migrate-h2IgRdlA/lib/python3.8/site-packages/atlassian/bitbucket/cloud/workspaces/projects.py", line 48, in create
    return self.__get_object(self.post(None, data=data))
  File "/home/mdavis/.local/share/virtualenvs/bb-server-cloud-migrate-h2IgRdlA/lib/python3.8/site-packages/atlassian/rest_client.py", line 303, in post
    response = self.request(
  File "/home/mdavis/.local/share/virtualenvs/bb-server-cloud-migrate-h2IgRdlA/lib/python3.8/site-packages/atlassian/rest_client.py", line 236, in request
    response.raise_for_status()
  File "/home/mdavis/.local/share/virtualenvs/bb-server-cloud-migrate-h2IgRdlA/lib/python3.8/site-packages/requests/models.py", line 943, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: https://api.bitbucket.org/2.0/workspaces/a-workspace-name/projects
```

Getting a 400: Bad Request on a POST for an item that already exists totally makes sense. So how do I do this "create" in an idempotent way? I saw that there's also a "Project" method in `projects.py` (singular vs. the plural "Projects") which has an "update" method. The update method uses a PUT instead of a POST, so great, I thought! I just need to use that. But when I try to call that method like so...

```
bitbucket_cloud.workspaces.get(args.workspace)projects.update(
    key="ABC",
    name="ABC Project",
    description="A useful description",
    is_private=True
)
```

I get `AttributeError: 'Projects' object has no attribute 'update'`. Similarly, the lint/debugger in VSCode doesn't show the `update` method as an available method in "projects".

I'm new to Python, so odds are I'm missing something here about how to call this method...but if not, then this seems like a bug.


Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> :exclamation: No coverage uploaded for pull request base (`master@a863ad7`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#section-missing-base-commit).
> The diff coverage is `62.90%`.

[![Impacted file tree graph](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758/graphs/tree.svg?width=650&height=150&src=pr&token=8cjzMhjIpa&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@            Coverage Diff            @@
##             master     #758   +/-   ##
=========================================
  Coverage          ?   36.37%           
=========================================
  Files             ?       32           
  Lines             ?     5800           
  Branches          ?      848           
=========================================
  Hits              ?     2110           
  Misses            ?     3597           
  Partials          ?       93           
```


| [Impacted Files](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [atlassian/bitbucket/cloud/workspaces/projects.py](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YXRsYXNzaWFuL2JpdGJ1Y2tldC9jbG91ZC93b3Jrc3BhY2VzL3Byb2plY3RzLnB5) | `47.82% <53.84%> (ø)` | |
| [...assian/bitbucket/server/projects/repos/\_\_init\_\_.py](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YXRsYXNzaWFuL2JpdGJ1Y2tldC9zZXJ2ZXIvcHJvamVjdHMvcmVwb3MvX19pbml0X18ucHk=) | `88.69% <53.84%> (ø)` | |
| [atlassian/bitbucket/cloud/repositories/\_\_init\_\_.py](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YXRsYXNzaWFuL2JpdGJ1Y2tldC9jbG91ZC9yZXBvc2l0b3JpZXMvX19pbml0X18ucHk=) | `58.33% <61.53%> (ø)` | |
| [atlassian/bitbucket/server/projects/\_\_init\_\_.py](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YXRsYXNzaWFuL2JpdGJ1Y2tldC9zZXJ2ZXIvcHJvamVjdHMvX19pbml0X18ucHk=) | `82.97% <61.53%> (ø)` | |
| [atlassian/bitbucket/cloud/workspaces/\_\_init\_\_.py](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YXRsYXNzaWFuL2JpdGJ1Y2tldC9jbG91ZC93b3Jrc3BhY2VzL19faW5pdF9fLnB5) | `70.76% <90.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Last update [a863ad7...1f392c1](https://codecov.io/gh/atlassian-api/atlassian-python-api/pull/758?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).

Spacetown: @rodalli Thanks for the fixes.
rodalli: @Spacetown Sorry for the delay in taking a look at this change. I've installed your updates from your fork via pip, but I'm not seeing the .exists method present in any of the expected classes based on your changes.

Maybe you can tell me if I'm doing something wrong, or if you're seeing different (could be that I didn't install your changes correctly, although I followed [the instructions here](https://github.com/atlassian-api/atlassian-python-api/blob/master/CONTRIBUTING.rst#using-your-changes-before-theyre-live)).

Just to make sure I had your changes:
```
pip3 uninstall atlassian-python-api
```

Then, after installing from your fork and using tab complete in REPL to explore the structure, I find that the exists method isn't available where expected:
```
mdavis@mdavis-stinkpad:~/code/github/atlassian-python-api$ python3 -m pip install git+https://github.com/Spacetown/atlassian-python-api.git
Collecting git+https://github.com/Spacetown/atlassian-python-api.git
  Cloning https://github.com/Spacetown/atlassian-python-api.git to /tmp/pip-req-build-1x6qv376
  Running command git clone -q https://github.com/Spacetown/atlassian-python-api.git /tmp/pip-req-build-1x6qv376
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
Requirement already satisfied: requests in /usr/lib/python3/dist-packages (from atlassian-python-api==2.6.0) (2.23.0)
Requirement already satisfied: six in /usr/lib/python3/dist-packages (from atlassian-python-api==2.6.0) (1.15.0)
Requirement already satisfied: oauthlib in /usr/lib/python3/dist-packages (from atlassian-python-api==2.6.0) (3.1.0)
Requirement already satisfied: requests-oauthlib in /home/mdavis/.local/lib/python3.8/site-packages (from atlassian-python-api==2.6.0) (1.3.0)
Requirement already satisfied: deprecated in /home/mdavis/.local/lib/python3.8/site-packages (from atlassian-python-api==2.6.0) (1.2.12)
Requirement already satisfied: wrapt<2,>=1.10 in /home/mdavis/.local/lib/python3.8/site-packages (from deprecated->atlassian-python-api==2.6.0) (1.12.1)
Building wheels for collected packages: atlassian-python-api
  Building wheel for atlassian-python-api (PEP 517) ... done
  Created wheel for atlassian-python-api: filename=atlassian_python_api-2.6.0-py3-none-any.whl size=124909 sha256=a679173782abf82fa8afd9c73a6c4d97553a18f95a92ead64adacc11ee3a2f15
  Stored in directory: /tmp/pip-ephem-wheel-cache-6c8_08jk/wheels/2a/a4/1f/fecca1c25ace99a70faec4e7a4579e2a929c448ad904a3fd81
Successfully built atlassian-python-api
Installing collected packages: atlassian-python-api
Successfully installed atlassian-python-api-2.6.0

mdavis@mdavis-stinkpad:~/code/github/atlassian-python-api$ python3
Python 3.8.6 (default, Jan 27 2021, 15:42:20) 
[GCC 10.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import atlassian
>>> atlassian.bitbucket.cloud.Workspaces.
atlassian.bitbucket.cloud.Workspaces.CONF_TIMEFORMAT               atlassian.bitbucket.cloud.Workspaces.get_link(
atlassian.bitbucket.cloud.Workspaces.bulk_headers                  atlassian.bitbucket.cloud.Workspaces.get_time(
atlassian.bitbucket.cloud.Workspaces.close(                        atlassian.bitbucket.cloud.Workspaces.log_curl_debug(
atlassian.bitbucket.cloud.Workspaces.data                          atlassian.bitbucket.cloud.Workspaces.mro(
atlassian.bitbucket.cloud.Workspaces.default_headers               atlassian.bitbucket.cloud.Workspaces.no_check_headers
atlassian.bitbucket.cloud.Workspaces.delete(                       atlassian.bitbucket.cloud.Workspaces.post(
atlassian.bitbucket.cloud.Workspaces.each(                         atlassian.bitbucket.cloud.Workspaces.put(
atlassian.bitbucket.cloud.Workspaces.experimental_headers          atlassian.bitbucket.cloud.Workspaces.request(
atlassian.bitbucket.cloud.Workspaces.experimental_headers_general  atlassian.bitbucket.cloud.Workspaces.resource_url(
atlassian.bitbucket.cloud.Workspaces.form_token_headers            atlassian.bitbucket.cloud.Workspaces.response
atlassian.bitbucket.cloud.Workspaces.get(                          atlassian.bitbucket.cloud.Workspaces.safe_mode_headers
atlassian.bitbucket.cloud.Workspaces.get_data(                     atlassian.bitbucket.cloud.Workspaces.url_joiner(
>>> atlassian.bitbucket.cloud.Workspaces.exists
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'Workspaces' has no attribute 'exists'
```

It's not present in Projects either:

```
>>> atlassian.bitbucket.cloud.workspaces.Projects.e
atlassian.bitbucket.cloud.workspaces.Projects.each(
atlassian.bitbucket.cloud.workspaces.Projects.experimental_headers
atlassian.bitbucket.cloud.workspaces.Projects.experimental_headers_general
```
Spacetown: @rodalli The change sin't on master you have to use `git+https://github.com/Spacetown/atlassian-python-api.git@bitbucket_cloud_proj_repo_exists`.
rodalli: Pfffft. I can't believe I missed that. Sorry @Spacetown ! Thanks for pointing out my error.

I've tested the functionality with projects and repositories and all is working as I'd expect!

```
>>> bitbucket_cloud.workspaces.get('pgi-tools-archive').repositories.exists('cd.desktop-aws')
True
>>> bitbucket_cloud.workspaces.get('pgi-tools-archive').repositories.exists('cd.desktop-awsq')
False
```

Nice job! 👍🏻 

Patch:
diff --git a/atlassian/bitbucket/cloud/repositories/__init__.py b/atlassian/bitbucket/cloud/repositories/__init__.py
index 47a08ed..f7a0050 100644
--- a/atlassian/bitbucket/cloud/repositories/__init__.py
+++ b/atlassian/bitbucket/cloud/repositories/__init__.py
@@ -1,5 +1,6 @@
 # coding=utf-8
 
+from requests import HTTPError
 from ..base import BitbucketCloudBase
 from .issues import Issues
 from .branchRestrictions import BranchRestrictions
@@ -110,6 +111,27 @@ class WorkspaceRepositories(RepositoriesBase):
 
         raise Exception("Unknown repository {} '{}'".format(by, repository))
 
+    def exists(self, repository, by="slug"):
+        """
+        Check if repository exist.
+
+        :param repository: string: The requested repository.
+        :param by: string (default is "slug"): How to interpret repository, can be 'slug' or 'name'.
+
+        :return: True if the repository exists
+        """
+        exists = False
+        try:
+            self.get(repository, by)
+            exists = True
+        except HTTPError as e:
+            if e.response.status_code in (401, 404):
+                pass
+        except Exception as e:
+            if not str(e) == "Unknown project {} '{}'".format(by, repository):
+                raise e
+        return exists
+
 
 class ProjectRepositories(RepositoriesBase):
     def __init__(self, url, *args, **kwargs):
diff --git a/atlassian/bitbucket/cloud/workspaces/__init__.py b/atlassian/bitbucket/cloud/workspaces/__init__.py
index 75a78df..43d2800 100644
--- a/atlassian/bitbucket/cloud/workspaces/__init__.py
+++ b/atlassian/bitbucket/cloud/workspaces/__init__.py
@@ -1,5 +1,6 @@
 # coding=utf-8
 
+from requests import HTTPError
 from ..base import BitbucketCloudBase
 
 from .projects import Projects
@@ -53,6 +54,23 @@ class Workspaces(BitbucketCloudBase):
         """
         return self.__get_object(super(Workspaces, self).get(workspace))
 
+    def exists(self, workspace):
+        """
+        Check if workspace exist.
+
+        :param workspace: string: The requested workspace.
+
+        :return: True if the workspace exists
+        """
+        exists = False
+        try:
+            self.get(workspace)
+            exists = True
+        except HTTPError as e:
+            if e.response.status_code in (401, 404):
+                pass
+        return exists
+
 
 class Workspace(BitbucketCloudBase):
     def __init__(self, data, *args, **kwargs):
diff --git a/atlassian/bitbucket/cloud/workspaces/projects.py b/atlassian/bitbucket/cloud/workspaces/projects.py
index 6125c34..fd8aa0d 100644
--- a/atlassian/bitbucket/cloud/workspaces/projects.py
+++ b/atlassian/bitbucket/cloud/workspaces/projects.py
@@ -1,5 +1,6 @@
 # coding=utf-8
 
+from requests import HTTPError
 from ..base import BitbucketCloudBase
 
 from ..repositories import ProjectRepositories
@@ -74,8 +75,8 @@ class Projects(BitbucketCloudBase):
         """
         Returns the requested project
 
-        :param project: string:               The requested project.
-        :param by: string (default is "key"): How to interprate project, can be 'key' or 'name'.
+        :param project: string: The requested project.
+        :param by: string (default is "key"): How to interpret project, can be 'key' or 'name'.
 
         :return: The requested Project object
         """
@@ -90,6 +91,27 @@ class Projects(BitbucketCloudBase):
 
         raise Exception("Unknown project {} '{}'".format(by, project))
 
+    def exists(self, project, by="key"):
+        """
+        Check if project exist.
+
+        :param project: string: The requested project.
+        :param by: string (default is "key"): How to interpret project, can be 'key' or 'name'.
+
+        :return: True if the project exists
+        """
+        exists = False
+        try:
+            self.get(project, by)
+            exists = True
+        except HTTPError as e:
+            if e.response.status_code in (401, 404):
+                pass
+        except Exception as e:
+            if not str(e) == "Unknown project {} '{}'".format(by, project):
+                raise e
+        return exists
+
 
 class Project(BitbucketCloudBase):
     def __init__(self, data, *args, **kwargs):
diff --git a/atlassian/bitbucket/server/projects/__init__.py b/atlassian/bitbucket/server/projects/__init__.py
index 2149986..d1950b4 100644
--- a/atlassian/bitbucket/server/projects/__init__.py
+++ b/atlassian/bitbucket/server/projects/__init__.py
@@ -1,5 +1,6 @@
 # coding=utf-8
 
+from requests import HTTPError
 from .repos import Repositories
 from ..base import BitbucketServerBase
 from ..common.permissions import Groups, Users
@@ -64,7 +65,7 @@ class Projects(BitbucketServerBase):
         Returns the requested project
 
         :param project: string: The requested project.
-        :param by: string: How to interprate project, can be 'key' or 'name'.
+        :param by: string (default is "key"): How to interpret project, can be 'key' or 'name'.
 
         :return: The requested Project object
 
@@ -81,6 +82,27 @@ class Projects(BitbucketServerBase):
 
         raise Exception("Unknown project {} '{}'".format(by, project))
 
+    def exists(self, project, by="key"):
+        """
+        Check if project exist.
+
+        :param project: string: The requested project.
+        :param by: string (default is "key"): How to interpret project, can be 'key' or 'name'.
+
+        :return: True if the project exists
+        """
+        exists = False
+        try:
+            self.get(project, by)
+            exists = True
+        except HTTPError as e:
+            if e.response.status_code in (401, 404):
+                pass
+        except Exception as e:
+            if not str(e) == "Unknown project {} '{}'".format(by, project):
+                raise e
+        return exists
+
 
 class Project(BitbucketServerBase):
     def __init__(self, data, *args, **kwargs):
diff --git a/atlassian/bitbucket/server/projects/repos/__init__.py b/atlassian/bitbucket/server/projects/repos/__init__.py
index ef655e3..2e6f115 100644
--- a/atlassian/bitbucket/server/projects/repos/__init__.py
+++ b/atlassian/bitbucket/server/projects/repos/__init__.py
@@ -1,5 +1,6 @@
 # coding=utf-8
 
+from requests import HTTPError
 from ...base import BitbucketServerBase
 from ...common.permissions import Groups, Users
 
@@ -39,7 +40,7 @@ class Repositories(BitbucketServerBase):
         Returns the requested repository.
 
         :param repository: string: The requested repository.
-        :param by: string: How to interprate project, can be 'slug' or 'name'.
+        :param by: string (default is "slug"): How to interpret project, can be 'slug' or 'name'.
 
         :return: The requested Repository object
         """
@@ -55,6 +56,27 @@ class Repositories(BitbucketServerBase):
 
         raise Exception("Unknown repository {} '{}'".format(by, repository))
 
+    def exists(self, repository, by="slug"):
+        """
+        Check if repository exist.
+
+        :param repository: string: The requested repository.
+        :param by: string (default is "slug"): How to interpret repository, can be 'slug' or 'name'.
+
+        :return: True if the repository exists
+        """
+        exists = False
+        try:
+            self.get(repository, by)
+            exists = True
+        except HTTPError as e:
+            if e.response.status_code in (401, 404):
+                pass
+        except Exception as e:
+            if not str(e) == "Unknown project {} '{}'".format(by, repository):
+                raise e
+        return exists
+
 
 class Repository(BitbucketServerBase):
     def __init__(self, data, *args, **kwargs):


Test patch:
diff --git a/tests/test_bitbucket_cloud.py b/tests/test_bitbucket_cloud.py
index 38c51b0..b4eed65 100644
--- a/tests/test_bitbucket_cloud.py
+++ b/tests/test_bitbucket_cloud.py
@@ -28,6 +28,23 @@ def _datetimetostr(dtime):
 
 @pytest.mark.skipif(sys.version_info < (3, 4), reason="requires python3.4")
 class TestBasic:
+    def test_exists_workspace(self):
+        assert CLOUD.workspaces.exists("TestWorkspace1"), "Exists workspace"
+
+    def test_not_exists_workspace(self):
+        assert not CLOUD.workspaces.exists("TestWorkspace1xxx"), "Not exists workspace"
+
+    def test_not_exists_project(self):
+        assert not CLOUD.workspaces.get("TestWorkspace1").projects.exists("pr1"), "Not exists repository"
+
+    def test_exists_repository(self):
+        assert CLOUD.workspaces.get("TestWorkspace1").repositories.exists("testrepository1"), "Exists repository"
+
+    def test_not_exists_repository(self):
+        assert not CLOUD.workspaces.get("TestWorkspace1").repositories.exists(
+            "testrepository1xxx"
+        ), "Not exists repository"
+
     def test_get_repositories(self):
         result = [x["name"] for x in BITBUCKET.get_repositories("TestWorkspace1")]
         assert result == ["testrepository1", "testrepository2"], "Result of [get_repositories(...)]"
diff --git a/tests/test_bitbucket_server.py b/tests/test_bitbucket_server.py
index 9bc37c6..14c8aef 100644
--- a/tests/test_bitbucket_server.py
+++ b/tests/test_bitbucket_server.py
@@ -79,6 +79,8 @@ class TestBasic:
         assert user.delete() == {}, "Delete a user"
 
     def test_projects(self):
+        assert not BITBUCKET.projects.exists("PRJxxx"), "Not exists project by key"
+        assert BITBUCKET.projects.exists("PRJ"), "Exists project by key"
         result = list(BITBUCKET.projects.each())
         assert [x.key for x in result] == ["PRJ", "PRJ1"], "Each project keys"
         assert [x.description for x in result] == [
@@ -86,8 +88,12 @@ class TestBasic:
             "The description for my cool project 1.",
         ], "Each project description"
 
+        assert not BITBUCKET.projects.exists("PRJxxx"), "Not exists project by key"
+        assert BITBUCKET.projects.exists("PRJ"), "Exists project by key"
         project = BITBUCKET.projects.get("PRJ")
         assert project.name == "My Cool Project", "Get project by key"
+        assert not BITBUCKET.projects.exists("My Cool Project xxx", by="name"), "Not exists project by name"
+        assert BITBUCKET.projects.exists("My Cool Project", by="name"), "Exists project by name"
         project = BITBUCKET.projects.get("My Cool Project", by="name")
         assert project.key == "PRJ", "Get project by name"
 
@@ -171,6 +177,8 @@ class TestBasic:
 
         print(list(project.repos.each()))
 
+        assert project.repos.exists("my-repo1-slug"), "Repo exists by slug"
+
         repo = project.repos.get("my-repo1-slug")
         assert repo.name == "My repo 1", "The repo name by slug"
         assert repo.id == 1, "The repo id"