Skip to content

Commit f9fed09

Browse files
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.
1 parent e6ba4b2 commit f9fed09

File tree

5 files changed

+177
-5
lines changed

5 files changed

+177
-5
lines changed

gitlab/utils.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,88 @@ def copy_dict(dest: Dict[str, Any], src: Dict[str, Any]) -> None:
5656
dest[k] = v
5757

5858

59+
class EncodedId(str):
60+
"""A custom `str` class that will return the URL-encoded value of the string.
61+
62+
Features:
63+
* Using it recursively will only url-encode the value once.
64+
* Can accept either `str` or `int` as input value.
65+
* Can be used in an f-string and output the URL-encoded string.
66+
67+
Reference to documentation on why this is necessary.
68+
69+
https://docs.gitlab.com/ee/api/index.html#namespaced-path-encoding
70+
71+
If using namespaced API requests, make sure that the NAMESPACE/PROJECT_PATH is
72+
URL-encoded. For example, / is represented by %2F
73+
74+
https://docs.gitlab.com/ee/api/index.html#path-parameters
75+
76+
Path parameters that are required to be URL-encoded must be followed. If not, it
77+
doesn’t match an API endpoint and responds with a 404. If there’s something in
78+
front of the API (for example, Apache), ensure that it doesn’t decode the
79+
URL-encoded path parameters.
80+
81+
82+
When creating an EncodedId instance `__new__` will be called first and then
83+
`__init__`.
84+
"""
85+
86+
# `original_str` will contain the original string value that was used to create the
87+
# first instance of EncodedId. We will use this original value to generate the
88+
# URL-encoded value each time.
89+
original_str: str
90+
91+
def __new__(cls, value: Union[str, int, "EncodedId"]) -> "EncodedId":
92+
if isinstance(value, int):
93+
value = str(value)
94+
# Make sure isinstance() for `EncodedId` comes before check for `str` as
95+
# `EncodedId` is an instance of `str` and would pass that check.
96+
elif isinstance(value, EncodedId):
97+
# We use the original string value to URL-encode
98+
value = value.original_str
99+
elif isinstance(value, str):
100+
pass
101+
else:
102+
raise ValueError(f"Unsupported type received: {type(value)}")
103+
# Set the value our string will return
104+
value = urllib.parse.quote(value, safe="")
105+
return super().__new__(cls, value)
106+
107+
def __init__(self, value: Union[int, str]) -> None:
108+
# At this point `super().__str__()` returns the URL-encoded value. Which means
109+
# when using this as a `str` it will return the URL-encoded value.
110+
#
111+
# But `value` contains the original value passed in `EncodedId(value)`. We use
112+
# this to always keep the original string that was received so that no matter
113+
# how many times we recurse we only URL-encode our original string once.
114+
if isinstance(value, int):
115+
value = str(value)
116+
# Make sure isinstance() for `EncodedId` comes before check for `str` as
117+
# `EncodedId` is an instance of `str` and would pass that check.
118+
elif isinstance(value, EncodedId):
119+
# This is the key part as we are always keeping the original string even
120+
# through multiple recursions.
121+
value = value.original_str
122+
elif isinstance(value, str):
123+
pass
124+
else:
125+
raise ValueError(f"Unsupported type received: {type(value)}")
126+
self.original_str = value
127+
super().__init__()
128+
129+
59130
@overload
60131
def _url_encode(id: int) -> int:
61132
...
62133

63134

64135
@overload
65-
def _url_encode(id: str) -> str:
136+
def _url_encode(id: Union[str, EncodedId]) -> EncodedId:
66137
...
67138

68139

69-
def _url_encode(id: Union[int, str]) -> Union[int, str]:
140+
def _url_encode(id: Union[int, str, EncodedId]) -> Union[int, EncodedId]:
70141
"""Encode/quote the characters in the string so that they can be used in a path.
71142
72143
Reference to documentation on why this is necessary.
@@ -84,9 +155,9 @@ def _url_encode(id: Union[int, str]) -> Union[int, str]:
84155
parameters.
85156
86157
"""
87-
if isinstance(id, int):
158+
if isinstance(id, (int, EncodedId)):
88159
return id
89-
return urllib.parse.quote(id, safe="")
160+
return EncodedId(id)
90161

91162

92163
def remove_none_from_dict(data: Dict[str, Any]) -> Dict[str, Any]:

tests/functional/api/test_groups.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def test_groups(gl):
100100
member = group1.members.get(user2.id)
101101
assert member.access_level == gitlab.const.OWNER_ACCESS
102102

103+
gl.auth()
103104
group2.members.delete(gl.user.id)
104105

105106

@@ -198,6 +199,11 @@ def test_group_subgroups_projects(gl, user):
198199
assert gr1_project.namespace["id"] == group1.id
199200
assert gr2_project.namespace["parent_id"] == group1.id
200201

202+
gr1_project.delete()
203+
gr2_project.delete()
204+
group3.delete()
205+
group4.delete()
206+
201207

202208
@pytest.mark.skip
203209
def test_group_wiki(group):
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import pytest
2+
3+
import gitlab
4+
5+
6+
@pytest.fixture
7+
def lazy_project(gl, project):
8+
assert "/" in project.path_with_namespace
9+
return gl.projects.get(project.path_with_namespace, lazy=True)
10+
11+
12+
def test_lazy_id(project, lazy_project):
13+
assert isinstance(lazy_project.id, str)
14+
assert isinstance(lazy_project.id, gitlab.utils.EncodedId)
15+
assert lazy_project.id == gitlab.utils._url_encode(project.path_with_namespace)
16+
17+
18+
def test_refresh_after_lazy_get_with_path(project, lazy_project):
19+
lazy_project.refresh()
20+
assert lazy_project.id == project.id
21+
22+
23+
def test_save_after_lazy_get_with_path(project, lazy_project):
24+
lazy_project.description = "A new description"
25+
lazy_project.save()
26+
assert lazy_project.id == project.id
27+
assert lazy_project.description == "A new description"
28+
29+
30+
def test_delete_after_lazy_get_with_path(gl, group, wait_for_sidekiq):
31+
project = gl.projects.create({"name": "lazy_project", "namespace_id": group.id})
32+
result = wait_for_sidekiq(timeout=60)
33+
assert result is True, "sidekiq process should have terminated but did not"
34+
lazy_project = gl.projects.get(project.path_with_namespace, lazy=True)
35+
lazy_project.delete()
36+
37+
38+
def test_method_call(gl, lazy_project):
39+
lazy_project.mergerequests.list()

tests/functional/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,8 @@ def user(gl):
406406
yield user
407407

408408
try:
409-
user.delete()
409+
# Use `hard_delete=True` or a 'Ghost User' may be created.
410+
user.delete(hard_delete=True)
410411
except gitlab.exceptions.GitlabDeleteError as e:
411412
print(f"User already deleted: {e}")
412413

tests/unit/test_utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
# You should have received a copy of the GNU Lesser General Public License
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717

18+
import json
19+
1820
from gitlab import utils
1921

2022

@@ -35,3 +37,56 @@ def test_url_encode():
3537
src = "docs/README.md"
3638
dest = "docs%2FREADME.md"
3739
assert dest == utils._url_encode(src)
40+
41+
42+
class TestEncodedId:
43+
def test_init_str(self):
44+
obj = utils.EncodedId("Hello")
45+
assert "Hello" == str(obj)
46+
assert "Hello" == f"{obj}"
47+
48+
obj = utils.EncodedId("this/is a/path")
49+
assert "this%2Fis%20a%2Fpath" == str(obj)
50+
assert "this%2Fis%20a%2Fpath" == f"{obj}"
51+
52+
def test_init_int(self):
53+
obj = utils.EncodedId(23)
54+
assert "23" == str(obj)
55+
assert "23" == f"{obj}"
56+
57+
def test_init_encodeid_str(self):
58+
value = "Goodbye"
59+
obj_init = utils.EncodedId(value)
60+
obj = utils.EncodedId(obj_init)
61+
assert value == str(obj)
62+
assert value == f"{obj}"
63+
assert value == obj.original_str
64+
65+
value = "we got/a/path"
66+
expected = "we%20got%2Fa%2Fpath"
67+
obj_init = utils.EncodedId(value)
68+
assert value == obj_init.original_str
69+
# Show that no matter how many times we recursively call it we still only
70+
# URL-encode it once.
71+
obj = utils.EncodedId(
72+
utils.EncodedId(utils.EncodedId(utils.EncodedId(utils.EncodedId(obj_init))))
73+
)
74+
assert expected == str(obj)
75+
assert expected == f"{obj}"
76+
# We have stored a copy of our original string
77+
assert value == obj.original_str
78+
79+
def test_init_encodeid_int(self):
80+
value = 23
81+
expected = f"{value}"
82+
obj_init = utils.EncodedId(value)
83+
obj = utils.EncodedId(obj_init)
84+
assert expected == str(obj)
85+
assert expected == f"{obj}"
86+
87+
def test_json_serializable(self):
88+
obj = utils.EncodedId("someone")
89+
assert '"someone"' == json.dumps(obj)
90+
91+
obj = utils.EncodedId("we got/a/path")
92+
assert '"we%20got%2Fa%2Fpath"' == json.dumps(obj)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy