summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn L. Villalovos <john@sodarock.com>2022-01-10 18:11:05 -0800
committerJohn L. Villalovos <john@sodarock.com>2022-01-13 10:31:24 -0800
commita2e7c383e10509b6eb0fa8760727036feb0807c8 (patch)
tree934b8e03c90239f1eaa80dcef347e0774ecb589f
parent12435d74364ca881373d690eab89d2e2baa62a49 (diff)
downloadgitlab-a2e7c383e10509b6eb0fa8760727036feb0807c8.tar.gz
chore: add EncodedId string class to use to hold URL-encoded paths
Add EncodedId string class. This class returns a URL-encoded string but ensures it will only URL-encode it once even if recursively called. Also added some functional tests of 'lazy' objects to make sure they work.
-rw-r--r--gitlab/mixins.py6
-rw-r--r--gitlab/utils.py68
-rw-r--r--gitlab/v4/objects/merge_request_approvals.py2
-rw-r--r--tests/functional/api/test_groups.py6
-rw-r--r--tests/functional/api/test_lazy_objects.py39
-rw-r--r--tests/functional/api/test_wikis.py1
-rw-r--r--tests/functional/conftest.py3
-rw-r--r--tests/unit/test_base.py4
-rw-r--r--tests/unit/test_utils.py62
9 files changed, 180 insertions, 11 deletions
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
index 1832247..a6794d0 100644
--- a/gitlab/mixins.py
+++ b/gitlab/mixins.py
@@ -542,8 +542,7 @@ class SaveMixin(_RestObjectBase):
return
# call the manager
- # Don't use `self.encoded_id` here as `self.manager.update()` will encode it.
- obj_id = self.get_id()
+ obj_id = self.encoded_id
if TYPE_CHECKING:
assert isinstance(self.manager, UpdateMixin)
server_data = self.manager.update(obj_id, updated_data, **kwargs)
@@ -573,8 +572,7 @@ class ObjectDeleteMixin(_RestObjectBase):
"""
if TYPE_CHECKING:
assert isinstance(self.manager, DeleteMixin)
- # Don't use `self.encoded_id` here as `self.manager.delete()` will encode it.
- self.manager.delete(self.get_id(), **kwargs)
+ self.manager.delete(self.encoded_id, **kwargs)
class UserAgentDetailMixin(_RestObjectBase):
diff --git a/gitlab/utils.py b/gitlab/utils.py
index 7914521..61e98f3 100644
--- a/gitlab/utils.py
+++ b/gitlab/utils.py
@@ -56,17 +56,77 @@ def copy_dict(dest: Dict[str, Any], src: Dict[str, Any]) -> None:
dest[k] = v
+class EncodedId(str):
+ """A custom `str` class that will return the URL-encoded value of the string.
+
+ * Using it recursively will only url-encode the value once.
+ * Can accept either `str` or `int` as input value.
+ * Can be used in an f-string and output the URL-encoded string.
+
+ Reference to documentation on why this is necessary.
+
+ See::
+
+ https://docs.gitlab.com/ee/api/index.html#namespaced-path-encoding
+ https://docs.gitlab.com/ee/api/index.html#path-parameters
+ """
+
+ # `original_str` will contain the original string value that was used to create the
+ # first instance of EncodedId. We will use this original value to generate the
+ # URL-encoded value each time.
+ original_str: str
+
+ def __new__(cls, value: Union[str, int, "EncodedId"]) -> "EncodedId":
+ # __new__() gets called before __init__()
+ if isinstance(value, int):
+ value = str(value)
+ # Make sure isinstance() for `EncodedId` comes before check for `str` as
+ # `EncodedId` is an instance of `str` and would pass that check.
+ elif isinstance(value, EncodedId):
+ # We use the original string value to URL-encode
+ value = value.original_str
+ elif isinstance(value, str):
+ pass
+ else:
+ raise ValueError(f"Unsupported type received: {type(value)}")
+ # Set the value our string will return
+ value = urllib.parse.quote(value, safe="")
+ return super().__new__(cls, value)
+
+ def __init__(self, value: Union[int, str]) -> None:
+ # At this point `super().__str__()` returns the URL-encoded value. Which means
+ # when using this as a `str` it will return the URL-encoded value.
+ #
+ # But `value` contains the original value passed in `EncodedId(value)`. We use
+ # this to always keep the original string that was received so that no matter
+ # how many times we recurse we only URL-encode our original string once.
+ if isinstance(value, int):
+ value = str(value)
+ # Make sure isinstance() for `EncodedId` comes before check for `str` as
+ # `EncodedId` is an instance of `str` and would pass that check.
+ elif isinstance(value, EncodedId):
+ # This is the key part as we are always keeping the original string even
+ # through multiple recursions.
+ value = value.original_str
+ elif isinstance(value, str):
+ pass
+ else:
+ raise ValueError(f"Unsupported type received: {type(value)}")
+ self.original_str = value
+ super().__init__()
+
+
@overload
def _url_encode(id: int) -> int:
...
@overload
-def _url_encode(id: str) -> str:
+def _url_encode(id: Union[str, EncodedId]) -> EncodedId:
...
-def _url_encode(id: Union[int, str]) -> Union[int, str]:
+def _url_encode(id: Union[int, str, EncodedId]) -> Union[int, EncodedId]:
"""Encode/quote the characters in the string so that they can be used in a path.
Reference to documentation on why this is necessary.
@@ -84,9 +144,9 @@ def _url_encode(id: Union[int, str]) -> Union[int, str]:
parameters.
"""
- if isinstance(id, int):
+ if isinstance(id, (int, EncodedId)):
return id
- return urllib.parse.quote(id, safe="")
+ return EncodedId(id)
def remove_none_from_dict(data: Dict[str, Any]) -> Dict[str, Any]:
diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py
index 2bbd399..45016d5 100644
--- a/gitlab/v4/objects/merge_request_approvals.py
+++ b/gitlab/v4/objects/merge_request_approvals.py
@@ -75,7 +75,7 @@ class ProjectApprovalManager(GetWithoutIdMixin, UpdateMixin, RESTManager):
if TYPE_CHECKING:
assert self._parent is not None
- path = f"/projects/{self._parent.get_id()}/approvers"
+ path = f"/projects/{self._parent.encoded_id}/approvers"
data = {"approver_ids": approver_ids, "approver_group_ids": approver_group_ids}
result = self.gitlab.http_put(path, post_data=data, **kwargs)
if TYPE_CHECKING:
diff --git a/tests/functional/api/test_groups.py b/tests/functional/api/test_groups.py
index 77562c1..105acbb 100644
--- a/tests/functional/api/test_groups.py
+++ b/tests/functional/api/test_groups.py
@@ -100,6 +100,7 @@ def test_groups(gl):
member = group1.members.get(user2.id)
assert member.access_level == gitlab.const.OWNER_ACCESS
+ gl.auth()
group2.members.delete(gl.user.id)
@@ -198,6 +199,11 @@ def test_group_subgroups_projects(gl, user):
assert gr1_project.namespace["id"] == group1.id
assert gr2_project.namespace["parent_id"] == group1.id
+ gr1_project.delete()
+ gr2_project.delete()
+ group3.delete()
+ group4.delete()
+
@pytest.mark.skip
def test_group_wiki(group):
diff --git a/tests/functional/api/test_lazy_objects.py b/tests/functional/api/test_lazy_objects.py
new file mode 100644
index 0000000..3db7a60
--- /dev/null
+++ b/tests/functional/api/test_lazy_objects.py
@@ -0,0 +1,39 @@
+import pytest
+
+import gitlab
+
+
+@pytest.fixture
+def lazy_project(gl, project):
+ assert "/" in project.path_with_namespace
+ return gl.projects.get(project.path_with_namespace, lazy=True)
+
+
+def test_lazy_id(project, lazy_project):
+ assert isinstance(lazy_project.id, str)
+ assert isinstance(lazy_project.id, gitlab.utils.EncodedId)
+ assert lazy_project.id == gitlab.utils._url_encode(project.path_with_namespace)
+
+
+def test_refresh_after_lazy_get_with_path(project, lazy_project):
+ lazy_project.refresh()
+ assert lazy_project.id == project.id
+
+
+def test_save_after_lazy_get_with_path(project, lazy_project):
+ lazy_project.description = "A new description"
+ lazy_project.save()
+ assert lazy_project.id == project.id
+ assert lazy_project.description == "A new description"
+
+
+def test_delete_after_lazy_get_with_path(gl, group, wait_for_sidekiq):
+ project = gl.projects.create({"name": "lazy_project", "namespace_id": group.id})
+ result = wait_for_sidekiq(timeout=60)
+ assert result is True, "sidekiq process should have terminated but did not"
+ lazy_project = gl.projects.get(project.path_with_namespace, lazy=True)
+ lazy_project.delete()
+
+
+def test_list_children_after_lazy_get_with_path(gl, lazy_project):
+ lazy_project.mergerequests.list()
diff --git a/tests/functional/api/test_wikis.py b/tests/functional/api/test_wikis.py
index 26ac244..bcb5e1f 100644
--- a/tests/functional/api/test_wikis.py
+++ b/tests/functional/api/test_wikis.py
@@ -5,7 +5,6 @@ https://docs.gitlab.com/ee/api/wikis.html
def test_wikis(project):
-
page = project.wikis.create({"title": "title/subtitle", "content": "test content"})
page.content = "update content"
page.title = "subtitle"
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
index 8b25c6c..e788646 100644
--- a/tests/functional/conftest.py
+++ b/tests/functional/conftest.py
@@ -406,7 +406,8 @@ def user(gl):
yield user
try:
- user.delete()
+ # Use `hard_delete=True` or a 'Ghost User' may be created.
+ user.delete(hard_delete=True)
except gitlab.exceptions.GitlabDeleteError as e:
print(f"User already deleted: {e}")
diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py
index 1493799..54c2e10 100644
--- a/tests/unit/test_base.py
+++ b/tests/unit/test_base.py
@@ -158,6 +158,10 @@ class TestRESTObject:
obj.id = "a/path"
assert "a%2Fpath" == obj.encoded_id
+ # If you assign it again it does not double URL-encode
+ obj.id = obj.encoded_id
+ assert "a%2Fpath" == obj.encoded_id
+
def test_custom_id_attr(self, fake_manager):
class OtherFakeObject(FakeObject):
_id_attr = "foo"
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index edb545b..cccab9d 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -15,6 +15,8 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import json
+
from gitlab import utils
@@ -35,3 +37,63 @@ def test_url_encode():
src = "docs/README.md"
dest = "docs%2FREADME.md"
assert dest == utils._url_encode(src)
+
+
+class TestEncodedId:
+ def test_init_str(self):
+ obj = utils.EncodedId("Hello")
+ assert "Hello" == str(obj)
+ assert "Hello" == f"{obj}"
+
+ obj = utils.EncodedId("this/is a/path")
+ assert "this%2Fis%20a%2Fpath" == str(obj)
+ assert "this%2Fis%20a%2Fpath" == f"{obj}"
+
+ def test_init_int(self):
+ obj = utils.EncodedId(23)
+ assert "23" == str(obj)
+ assert "23" == f"{obj}"
+
+ def test_init_encodeid_str(self):
+ value = "Goodbye"
+ obj_init = utils.EncodedId(value)
+ obj = utils.EncodedId(obj_init)
+ assert value == str(obj)
+ assert value == f"{obj}"
+ assert value == obj.original_str
+
+ value = "we got/a/path"
+ expected = "we%20got%2Fa%2Fpath"
+ obj_init = utils.EncodedId(value)
+ assert value == obj_init.original_str
+ assert expected == str(obj_init)
+ assert expected == f"{obj_init}"
+ # Show that no matter how many times we recursively call it we still only
+ # URL-encode it once.
+ obj = utils.EncodedId(
+ utils.EncodedId(utils.EncodedId(utils.EncodedId(utils.EncodedId(obj_init))))
+ )
+ assert expected == str(obj)
+ assert expected == f"{obj}"
+ # We have stored a copy of our original string
+ assert value == obj.original_str
+
+ # Show assignments still only encode once
+ obj2 = obj
+ assert expected == str(obj2)
+ assert expected == f"{obj2}"
+
+ def test_init_encodeid_int(self):
+ value = 23
+ expected = f"{value}"
+ obj_init = utils.EncodedId(value)
+ obj = utils.EncodedId(obj_init)
+ assert expected == str(obj)
+ assert expected == f"{obj}"
+
+ def test_json_serializable(self):
+ obj = utils.EncodedId("someone")
+ assert '"someone"' == json.dumps(obj)
+
+ obj = utils.EncodedId("we got/a/path")
+ assert '"we%20got%2Fa%2Fpath"' == json.dumps(obj)