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

Lines changed: 70 additions & 4 deletions
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

Lines changed: 1 addition & 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

Lines changed: 17 additions & 0 deletions
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

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