Skip to content

Commit 9896154

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 9896154

File tree

4 files changed

+143
-4
lines changed

4 files changed

+143
-4
lines changed

gitlab/utils.py

+70-4
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,83 @@ 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+
# `original_str` will contain the original string value that was used to create the
82+
# first instance of EncodedId. We will use this original value to generate the
83+
# URL-encoded value each time.
84+
original_str: str
85+
86+
def __init__(self, value: Union[int, str]) -> None:
87+
# At this point `super().__str__()` returns the URL-encoded value. Which means
88+
# when using this as a `str` it will return the URL-encoded value.
89+
#
90+
# But `value` contains the original value passed in `EncodedId(value)`. We use
91+
# this to always keep the original string that was received so that no matter
92+
# how many times we recurse we only URL-encode our original string once.
93+
if isinstance(value, int):
94+
value = str(value)
95+
# Make sure isinstance() for `EncodedId` comes before check for `str` as
96+
# `EncodedId` is an instance of `str` and would pass that check.
97+
elif isinstance(value, EncodedId):
98+
# This is the key part as we are always keeping the original string even
99+
# through multiple recursions.
100+
value = value.original_str
101+
elif isinstance(value, str):
102+
pass
103+
else:
104+
raise ValueError(f"Unsupported type received: {type(value)}")
105+
self.original_str = value
106+
super().__init__()
107+
108+
def __new__(cls, value: Union[str, int, "EncodedId"]) -> "EncodedId":
109+
if isinstance(value, int):
110+
value = str(value)
111+
# Make sure isinstance() for `EncodedId` comes before check for `str` as
112+
# `EncodedId` is an instance of `str` and would pass that check.
113+
elif isinstance(value, EncodedId):
114+
# We use the original string value to URL-encode
115+
value = value.original_str
116+
elif isinstance(value, str):
117+
pass
118+
else:
119+
raise ValueError(f"Unsupported type received: {type(value)}")
120+
# Set the value our string will return
121+
value = urllib.parse.quote(value, safe="")
122+
return super().__new__(cls, value)
123+
124+
59125
@overload
60126
def _url_encode(id: int) -> int:
61127
...
62128

63129

64130
@overload
65-
def _url_encode(id: str) -> str:
131+
def _url_encode(id: Union[str, EncodedId]) -> EncodedId:
66132
...
67133

68134

69-
def _url_encode(id: Union[int, str]) -> Union[int, str]:
135+
def _url_encode(id: Union[int, str, EncodedId]) -> Union[int, EncodedId]:
70136
"""Encode/quote the characters in the string so that they can be used in a path.
71137
72138
Reference to documentation on why this is necessary.
@@ -84,9 +150,9 @@ def _url_encode(id: Union[int, str]) -> Union[int, str]:
84150
parameters.
85151
86152
"""
87-
if isinstance(id, int):
153+
if isinstance(id, (int, EncodedId)):
88154
return id
89-
return urllib.parse.quote(id, safe="")
155+
return EncodedId(id)
90156

91157

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

tests/functional/api/test_groups.py

+1
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

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
def test_lazy_objects(gl, group):
2+
3+
group1 = gl.groups.create({"name": "group1", "path": "group1"})
4+
gr1_project = gl.projects.create({"name": "gr1_project", "namespace_id": group1.id})
5+
6+
lazy_project = gl.projects.get(gr1_project.path_with_namespace, lazy=True)
7+
lazy_project.refresh()
8+
9+
lazy_project = gl.projects.get(gr1_project.path_with_namespace, lazy=True)
10+
lazy_project.mergerequests.list()
11+
12+
lazy_project = gl.projects.get(gr1_project.path_with_namespace, lazy=True)
13+
lazy_project.description = "My stuff"
14+
lazy_project.save()
15+
16+
gr1_project.delete()
17+
group1.delete()

tests/unit/test_utils.py

+55
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)