datatypes 0.31.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datatypes/__init__.py +169 -0
- datatypes/collections/__init__.py +25 -0
- datatypes/collections/container.py +248 -0
- datatypes/collections/mapping.py +1226 -0
- datatypes/collections/sequence.py +311 -0
- datatypes/command.py +596 -0
- datatypes/compat.py +206 -0
- datatypes/config/__init__.py +13 -0
- datatypes/config/environ/__init__.py +33 -0
- datatypes/config/environ/base.py +332 -0
- datatypes/config/environ/token.py +173 -0
- datatypes/config/parser.py +256 -0
- datatypes/config/settings.py +352 -0
- datatypes/copy.py +151 -0
- datatypes/csv.py +547 -0
- datatypes/datetime.py +1243 -0
- datatypes/decorators/__init__.py +31 -0
- datatypes/decorators/base.py +449 -0
- datatypes/decorators/descriptor.py +490 -0
- datatypes/decorators/misc.py +152 -0
- datatypes/email.py +439 -0
- datatypes/enum.py +176 -0
- datatypes/event.py +299 -0
- datatypes/exception.py +13 -0
- datatypes/filetypes/__init__.py +10 -0
- datatypes/filetypes/html.py +540 -0
- datatypes/filetypes/toml.py +275 -0
- datatypes/http.py +1443 -0
- datatypes/logging.py +475 -0
- datatypes/number.py +586 -0
- datatypes/parse.py +362 -0
- datatypes/path.py +4358 -0
- datatypes/profile.py +246 -0
- datatypes/reflection/__init__.py +24 -0
- datatypes/reflection/inspect.py +3475 -0
- datatypes/reflection/other.py +843 -0
- datatypes/reflection/path.py +639 -0
- datatypes/server.py +642 -0
- datatypes/service.py +212 -0
- datatypes/ssh.py +812 -0
- datatypes/string.py +1632 -0
- datatypes/token/__init__.py +16 -0
- datatypes/token/abnf.py +1696 -0
- datatypes/token/base.py +921 -0
- datatypes/token/word.py +259 -0
- datatypes/typing.py +126 -0
- datatypes/url.py +988 -0
- datatypes/utils.py +179 -0
- datatypes-0.31.0.dist-info/METADATA +38 -0
- datatypes-0.31.0.dist-info/RECORD +53 -0
- datatypes-0.31.0.dist-info/WHEEL +5 -0
- datatypes-0.31.0.dist-info/licenses/LICENSE.txt +21 -0
- datatypes-0.31.0.dist-info/top_level.txt +1 -0
datatypes/url.py
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import re
|
|
3
|
+
import inspect
|
|
4
|
+
from socket import gethostname
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .compat import *
|
|
8
|
+
from .string import String, ByteString
|
|
9
|
+
from .token import StopWordTokenizer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Url(String):
|
|
13
|
+
r"""A url string on steroids, this is here to make it easy to manipulate
|
|
14
|
+
urls.
|
|
15
|
+
|
|
16
|
+
It tries to map the supported fields to their urlparse equivalents, with
|
|
17
|
+
some additions
|
|
18
|
+
|
|
19
|
+
https://en.wikipedia.org/wiki/URL
|
|
20
|
+
https://tools.ietf.org/html/rfc3986.html
|
|
21
|
+
|
|
22
|
+
A URI can be further classified as a locator, a name, or both. The
|
|
23
|
+
term "Uniform Resource Locator" (URL) refers to the subset of URIs
|
|
24
|
+
that, in addition to identifying a resource, provide a means of
|
|
25
|
+
locating the resource by describing its primary access mechanism
|
|
26
|
+
(e.g., its network "location").
|
|
27
|
+
|
|
28
|
+
foo://example.com:8042/over/there?name=ferret#nose
|
|
29
|
+
\_/ \______________/\_________/ \_________/ \__/
|
|
30
|
+
| | | | |
|
|
31
|
+
scheme authority path query fragment
|
|
32
|
+
|
|
33
|
+
Given a url:
|
|
34
|
+
|
|
35
|
+
http://user:pass@foo.com:1000/bar/che?baz=boom#anchor
|
|
36
|
+
|
|
37
|
+
.scheme = http
|
|
38
|
+
.netloc = user:pass@foo.com:1000
|
|
39
|
+
.authority = user:pass@foo.com:1000
|
|
40
|
+
.username = user
|
|
41
|
+
.password = pass
|
|
42
|
+
.userinfo = user:pass
|
|
43
|
+
.hostloc = foo.com:1000
|
|
44
|
+
.hostname = foo.com
|
|
45
|
+
.port = 1000
|
|
46
|
+
.path = bar/che
|
|
47
|
+
.fragment = anchor
|
|
48
|
+
.anchor = anchor
|
|
49
|
+
.uri = /bar/che?baz=boom#anchor
|
|
50
|
+
.host(...) = http://foo.com/...
|
|
51
|
+
.base(...) = http://user:pass@foo.com/bar/che/...
|
|
52
|
+
"""
|
|
53
|
+
scheme = ""
|
|
54
|
+
"""The http scheme (eg, "http" or "https")
|
|
55
|
+
|
|
56
|
+
By default this class won't set a scheme except when it can be "correctly"
|
|
57
|
+
inferred. But a child class could set the scheme to a default value and
|
|
58
|
+
then it will fallback to the set default
|
|
59
|
+
|
|
60
|
+
:example:
|
|
61
|
+
u = Url("//example.com")
|
|
62
|
+
u.scheme # "" because the // says the scheme wasn't wanted
|
|
63
|
+
u = Url("example.com:80")
|
|
64
|
+
u.scheme # "http" because a host was passed in with the plain port
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
username = None
|
|
68
|
+
|
|
69
|
+
password = None
|
|
70
|
+
|
|
71
|
+
hostname = ""
|
|
72
|
+
|
|
73
|
+
port = None
|
|
74
|
+
|
|
75
|
+
netloc = ""
|
|
76
|
+
|
|
77
|
+
path = ""
|
|
78
|
+
|
|
79
|
+
query_kwargs = {}
|
|
80
|
+
|
|
81
|
+
fragment = ""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def root(self):
|
|
85
|
+
"""just return scheme://netloc"""
|
|
86
|
+
if netloc := self.netloc:
|
|
87
|
+
ret = parse.urlunsplit((
|
|
88
|
+
self.scheme,
|
|
89
|
+
self.netloc,
|
|
90
|
+
"",
|
|
91
|
+
"",
|
|
92
|
+
""
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
else:
|
|
96
|
+
if self.scheme.startswith("http"):
|
|
97
|
+
ret = ""
|
|
98
|
+
|
|
99
|
+
else:
|
|
100
|
+
ret = self.scheme
|
|
101
|
+
|
|
102
|
+
return ret
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def anchor(self):
|
|
106
|
+
"""alternative name for fragment"""
|
|
107
|
+
return self.fragment
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def authority(self):
|
|
111
|
+
"""This is the official RFC name for netloc"""
|
|
112
|
+
return self.netloc
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def userinfo(self):
|
|
116
|
+
"""The Wikipedia name for the user information before the @
|
|
117
|
+
|
|
118
|
+
https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax
|
|
119
|
+
|
|
120
|
+
:returns: str, a string containing <USERNAME>:<PASSWORD>
|
|
121
|
+
"""
|
|
122
|
+
username = self.username or ""
|
|
123
|
+
password = self.password or ""
|
|
124
|
+
return f"{username}:{password}"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def absolute(self):
|
|
128
|
+
"""return the absolute URI, which is everything but base (no scheme,
|
|
129
|
+
host, etc). It's basically the path onwards
|
|
130
|
+
"""
|
|
131
|
+
uristring = self.path
|
|
132
|
+
if self.query:
|
|
133
|
+
uristring += "?{}".format(self.query)
|
|
134
|
+
if self.fragment:
|
|
135
|
+
uristring += "#{}".format(self.fragment)
|
|
136
|
+
|
|
137
|
+
return uristring
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def paths(self):
|
|
141
|
+
"""Returns the list of breadcrumbs for path, similar to path.Path.paths
|
|
142
|
+
|
|
143
|
+
Moved from bang.utils.Url on 1-6-2023
|
|
144
|
+
|
|
145
|
+
:returns: list, so if path was /foo/bar/che this would return
|
|
146
|
+
[/foo, /foo/bar, /foo/bar/che]
|
|
147
|
+
"""
|
|
148
|
+
ret = []
|
|
149
|
+
paths = self.parts
|
|
150
|
+
|
|
151
|
+
for x in range(1, len(paths) + 1):
|
|
152
|
+
ret.append("/" + "/".join(paths[0:x]))
|
|
153
|
+
|
|
154
|
+
return ret
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def parts(self):
|
|
158
|
+
"""Return a list of the path parts, similar to path.Path.parts"""
|
|
159
|
+
path = self.path.strip("/")
|
|
160
|
+
return path.split("/") if path else []
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def ext(self):
|
|
164
|
+
"""return the extension of the file, the basename without the fileroot
|
|
165
|
+
|
|
166
|
+
Moved from bang.utils.Url.ext on 1-10-2023
|
|
167
|
+
"""
|
|
168
|
+
return os.path.splitext(self.basename)[1].lstrip(".")
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def extension(self):
|
|
172
|
+
return self.ext
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def basename(self):
|
|
176
|
+
"""Returns the basename of the path portion of the url
|
|
177
|
+
|
|
178
|
+
Moved from bang.utils.Url.ext on 1-10-2023
|
|
179
|
+
"""
|
|
180
|
+
return os.path.basename(self.path)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def keys(cls):
|
|
184
|
+
# we need to ignore property objects also
|
|
185
|
+
def is_valid(k, v):
|
|
186
|
+
return not k.startswith("_") \
|
|
187
|
+
and not callable(v) \
|
|
188
|
+
and not isinstance(v, property) \
|
|
189
|
+
and not k.isupper()
|
|
190
|
+
|
|
191
|
+
keys = set(k for k, v in inspect.getmembers(cls) if is_valid(k, v))
|
|
192
|
+
return keys
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def default_values(cls):
|
|
196
|
+
"""Return the default values for the defined keys in the class
|
|
197
|
+
|
|
198
|
+
:returns: dict, the key is the default class variable name and the value
|
|
199
|
+
is the value it is set to
|
|
200
|
+
"""
|
|
201
|
+
values = {}
|
|
202
|
+
for k in cls.keys():
|
|
203
|
+
v = getattr(cls, k)
|
|
204
|
+
if isinstance(v, dict):
|
|
205
|
+
values[k] = dict(v)
|
|
206
|
+
|
|
207
|
+
elif isinstance(v, list):
|
|
208
|
+
values[k] = list(v)
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
values[k] = v
|
|
212
|
+
|
|
213
|
+
return values
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def merge(cls, urlstring="", *args, **kwargs):
|
|
217
|
+
# we handle port before any other because the port of host:port in
|
|
218
|
+
# hostname takes precedence the port on the host would take precedence
|
|
219
|
+
# because proxies mean that the host can be something:10000 and the
|
|
220
|
+
# port could be 9000 because 10000 is being proxied to 9000 on the
|
|
221
|
+
# machine, but we want to automatically account for things like that
|
|
222
|
+
# and then if custom behavior is needed then this method can be
|
|
223
|
+
# overridden
|
|
224
|
+
parts = cls.default_values()
|
|
225
|
+
|
|
226
|
+
# we're going to remove our default scheme so we can make sure we set
|
|
227
|
+
# everything up correctly using passed in data, we'll add it back after
|
|
228
|
+
# we've got everything we needed
|
|
229
|
+
default_scheme = parts.pop("scheme", "")
|
|
230
|
+
|
|
231
|
+
if urlstring:
|
|
232
|
+
properties = [
|
|
233
|
+
"scheme", # 0
|
|
234
|
+
"netloc", # 1
|
|
235
|
+
"path", # 2
|
|
236
|
+
"query", # 3
|
|
237
|
+
"fragment", # 4
|
|
238
|
+
"username",
|
|
239
|
+
"password",
|
|
240
|
+
"hostname",
|
|
241
|
+
"port",
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
has_host = is_url = False
|
|
245
|
+
|
|
246
|
+
if is_url := cls.is_url(urlstring):
|
|
247
|
+
o = parse.urlsplit(String(urlstring))
|
|
248
|
+
|
|
249
|
+
else:
|
|
250
|
+
s = String(urlstring)
|
|
251
|
+
part = s.split("/", maxsplit=1)[0]
|
|
252
|
+
has_host = "." in part or ":" in part \
|
|
253
|
+
or part.lower().startswith("localhost") \
|
|
254
|
+
or part.startswith("127.0.0.1")
|
|
255
|
+
|
|
256
|
+
if has_host:
|
|
257
|
+
# if we don't have a url but it looks like we have a host
|
|
258
|
+
# so let's make it a url by putting // in front of it so it
|
|
259
|
+
# will still parse correctly
|
|
260
|
+
s = "//{}".format(String(urlstring))
|
|
261
|
+
|
|
262
|
+
o = parse.urlsplit(s)
|
|
263
|
+
|
|
264
|
+
for k in properties:
|
|
265
|
+
v = getattr(o, k)
|
|
266
|
+
if v:
|
|
267
|
+
parts[k] = v
|
|
268
|
+
|
|
269
|
+
if is_url:
|
|
270
|
+
parts["scheme"] = getattr(o, "scheme")
|
|
271
|
+
|
|
272
|
+
query = parts.get("query", "")
|
|
273
|
+
if query:
|
|
274
|
+
parts["query_kwargs"].update(cls.parse_query(query))
|
|
275
|
+
|
|
276
|
+
query = kwargs.pop("query", "")
|
|
277
|
+
if query:
|
|
278
|
+
parts["query_kwargs"].update(cls.parse_query(query))
|
|
279
|
+
|
|
280
|
+
query_kwargs = kwargs.pop("query_kwargs", {})
|
|
281
|
+
if query_kwargs:
|
|
282
|
+
parts["query_kwargs"].update(query_kwargs)
|
|
283
|
+
|
|
284
|
+
parts["query"] = ""
|
|
285
|
+
if parts["query_kwargs"]:
|
|
286
|
+
parts["query"] = cls.unparse_query(parts["query_kwargs"])
|
|
287
|
+
|
|
288
|
+
ports = kwargs.pop("ports", {
|
|
289
|
+
"": 80,
|
|
290
|
+
"http": 80,
|
|
291
|
+
"https": 443,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
for k, v in kwargs.items():
|
|
295
|
+
parts[k] = v
|
|
296
|
+
|
|
297
|
+
domain, port = cls.split_hostname_from_port(parts["hostname"])
|
|
298
|
+
parts["hostname"] = domain
|
|
299
|
+
if port:
|
|
300
|
+
parts["port"] = kwargs.get("port", port)
|
|
301
|
+
|
|
302
|
+
if not parts.get("port", None):
|
|
303
|
+
if "default_port" in kwargs:
|
|
304
|
+
parts["port"] = kwargs["default_port"]
|
|
305
|
+
|
|
306
|
+
else:
|
|
307
|
+
parts["port"] = ports.get(
|
|
308
|
+
parts.get("scheme", default_scheme),
|
|
309
|
+
None
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# make sure port is an int
|
|
313
|
+
if parts["port"]:
|
|
314
|
+
parts["port"] = int(parts["port"])
|
|
315
|
+
|
|
316
|
+
hostloc = parts["hostname"]
|
|
317
|
+
port = parts["port"]
|
|
318
|
+
# we don't want common ports to be a part of a .geturl() call, but we
|
|
319
|
+
# do want .port to return them
|
|
320
|
+
if port and port not in set(ports.values()):
|
|
321
|
+
hostloc = '{}:{}'.format(hostloc, port)
|
|
322
|
+
parts["hostloc"] = hostloc
|
|
323
|
+
|
|
324
|
+
parts["netloc"] = parts["hostloc"]
|
|
325
|
+
username = kwargs.get("username", parts["username"])
|
|
326
|
+
password = kwargs.get("password", parts["password"])
|
|
327
|
+
if username:
|
|
328
|
+
parts["netloc"] = "{}:{}@{}".format(
|
|
329
|
+
username or "",
|
|
330
|
+
password or "",
|
|
331
|
+
parts["hostloc"]
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
parts["path"] = "/".join(cls.normalize_paths(parts["path"], *args))
|
|
335
|
+
# compensate for "/" being the first path part
|
|
336
|
+
if parts["path"].startswith("//"):
|
|
337
|
+
parts["path"] = parts["path"][1:]
|
|
338
|
+
|
|
339
|
+
if parts["path"]:
|
|
340
|
+
if parts["hostname"]:
|
|
341
|
+
# if path exists than we want to make sure it has a starting /
|
|
342
|
+
# if a hostname exists also, user could've passed in a relative
|
|
343
|
+
# path but we'll need to assume it is absolute since host
|
|
344
|
+
# exists
|
|
345
|
+
if not parts["path"].startswith("/"):
|
|
346
|
+
parts["path"] = "/" + parts["path"]
|
|
347
|
+
|
|
348
|
+
else:
|
|
349
|
+
if not parts.get("scheme", ""):
|
|
350
|
+
parts["scheme"] = ""
|
|
351
|
+
|
|
352
|
+
# let's add our default scheme now that we've generated everything
|
|
353
|
+
# we needed with the passed in values
|
|
354
|
+
if "scheme" not in parts:
|
|
355
|
+
if default_scheme:
|
|
356
|
+
parts.setdefault("scheme", default_scheme)
|
|
357
|
+
|
|
358
|
+
else:
|
|
359
|
+
if parts.get("hostloc", ""):
|
|
360
|
+
if parts.get("port", None) == 443:
|
|
361
|
+
parts.setdefault("scheme", "https")
|
|
362
|
+
|
|
363
|
+
else:
|
|
364
|
+
parts.setdefault("scheme", "http")
|
|
365
|
+
|
|
366
|
+
if not parts.get("scheme", ""):
|
|
367
|
+
parts["scheme"] = ""
|
|
368
|
+
|
|
369
|
+
parts["urlstring"] = parse.urlunsplit((
|
|
370
|
+
parts["scheme"],
|
|
371
|
+
parts["netloc"],
|
|
372
|
+
parts["path"],
|
|
373
|
+
parts["query"],
|
|
374
|
+
parts["fragment"],
|
|
375
|
+
))
|
|
376
|
+
|
|
377
|
+
for k in parts:
|
|
378
|
+
if isinstance(parts[k], bytes):
|
|
379
|
+
parts[k] = String(parts[k])
|
|
380
|
+
|
|
381
|
+
return parts
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def parse_query(cls, query):
|
|
385
|
+
"""return name=val&name2=val2 strings into {name: val} dict"""
|
|
386
|
+
if not query:
|
|
387
|
+
return {}
|
|
388
|
+
|
|
389
|
+
if isinstance(query, bytes):
|
|
390
|
+
query = String(query)
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
# https://docs.python.org/2/library/urlparse.html
|
|
394
|
+
query_kwargs = parse.parse_qs(query, True, strict_parsing=True)
|
|
395
|
+
|
|
396
|
+
except ValueError:
|
|
397
|
+
# try and parse the query manually, this will allow boolean values
|
|
398
|
+
# (arguments with no value)
|
|
399
|
+
query_kwargs = {}
|
|
400
|
+
for part in query.split("&"):
|
|
401
|
+
if "=" in part:
|
|
402
|
+
k, v = part.split("=", 1)
|
|
403
|
+
else:
|
|
404
|
+
k = part
|
|
405
|
+
v = "True"
|
|
406
|
+
query_kwargs.setdefault(k, [])
|
|
407
|
+
query_kwargs[k].append(v)
|
|
408
|
+
|
|
409
|
+
return cls.normalize_query_kwargs(query_kwargs)
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def normalize_query_kwargs(cls, query_kwargs):
|
|
413
|
+
d = {}
|
|
414
|
+
# https://docs.python.org/2/library/urlparse.html
|
|
415
|
+
for k, kv in query_kwargs.items():
|
|
416
|
+
#k = k.rstrip("[]") # strip out php type array designated variables
|
|
417
|
+
if isinstance(k, bytes):
|
|
418
|
+
k = String(k)
|
|
419
|
+
|
|
420
|
+
if len(kv) > 1:
|
|
421
|
+
d[k] = kv
|
|
422
|
+
|
|
423
|
+
else:
|
|
424
|
+
d[k] = kv[0]
|
|
425
|
+
|
|
426
|
+
return d
|
|
427
|
+
|
|
428
|
+
@classmethod
|
|
429
|
+
def unparse_query(cls, query_kwargs):
|
|
430
|
+
"""Given query kwargs in a dict format, convert them back into a string
|
|
431
|
+
|
|
432
|
+
This will encode [] in the name as discussed in:
|
|
433
|
+
https://stackoverflow.com/a/39294315/5006
|
|
434
|
+
https://github.com/ljharb/qs/issues/235
|
|
435
|
+
|
|
436
|
+
Luckily, it seems most servers will transparently handle encoded [] in
|
|
437
|
+
variable names
|
|
438
|
+
|
|
439
|
+
:param query_kwargs: dict, eg {"foo": "bar", "che": 1}
|
|
440
|
+
:returns: str, foo=bar&che=1
|
|
441
|
+
"""
|
|
442
|
+
return urlencode(query_kwargs, doseq=True)
|
|
443
|
+
|
|
444
|
+
@classmethod
|
|
445
|
+
def normalize_paths(cls, *paths):
|
|
446
|
+
"""turns a bunch of paths into something that can be concatenated
|
|
447
|
+
without any issues
|
|
448
|
+
|
|
449
|
+
:param *parts: str|list, things like "/foo/bar" or ["foo", "bar/che"]
|
|
450
|
+
:returns: list, a list of normalized parts with most of the "/" stripped
|
|
451
|
+
out, the exception is if the path is absolute then the first part
|
|
452
|
+
will be "/", so "/foo/bar" -> ["/", "foo", "bar"]
|
|
453
|
+
"""
|
|
454
|
+
args = []
|
|
455
|
+
for ps in paths:
|
|
456
|
+
if not ps:
|
|
457
|
+
continue
|
|
458
|
+
if ps == ".":
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
if isinstance(ps, int):
|
|
462
|
+
args.append(String(ps))
|
|
463
|
+
|
|
464
|
+
elif isinstance(ps, basestring):
|
|
465
|
+
if not args and ps.startswith("/"):
|
|
466
|
+
args.append("/")
|
|
467
|
+
ps.lstrip("/")
|
|
468
|
+
|
|
469
|
+
args.extend(filter(None, ps.split("/")))
|
|
470
|
+
|
|
471
|
+
else:
|
|
472
|
+
for p in ps:
|
|
473
|
+
nps = cls.normalize_paths(p)
|
|
474
|
+
if nps:
|
|
475
|
+
if args and nps[0] == "/":
|
|
476
|
+
args.extend(nps[1:])
|
|
477
|
+
else:
|
|
478
|
+
args.extend(nps)
|
|
479
|
+
return args
|
|
480
|
+
|
|
481
|
+
@classmethod
|
|
482
|
+
def split_hostname_from_port(cls, hostname, default_port=None):
|
|
483
|
+
"""given a hostname:port return a tuple (hostname, port)"""
|
|
484
|
+
bits = hostname.split(":", 2)
|
|
485
|
+
p = int(default_port) if default_port else default_port
|
|
486
|
+
d = bits[0]
|
|
487
|
+
if len(bits) == 2:
|
|
488
|
+
p = int(bits[1])
|
|
489
|
+
return d, p
|
|
490
|
+
|
|
491
|
+
@classmethod
|
|
492
|
+
def is_full_url(cls, urlstring):
|
|
493
|
+
"""Return True if urlstring is a url"""
|
|
494
|
+
return True if re.match(r"^\S+://", urlstring) else False
|
|
495
|
+
|
|
496
|
+
@classmethod
|
|
497
|
+
def is_relative_url(cls, urlstring):
|
|
498
|
+
"""Return True if urlstring is a relative url (does not have a scheme
|
|
499
|
+
but instead starts with //)
|
|
500
|
+
"""
|
|
501
|
+
return urlstring.startswith("//")
|
|
502
|
+
|
|
503
|
+
@classmethod
|
|
504
|
+
def is_path_url(cls, urlstring):
|
|
505
|
+
"""Return true if this is a path url (url that doesn't have a host)
|
|
506
|
+
|
|
507
|
+
a path url: /foo/bar
|
|
508
|
+
"""
|
|
509
|
+
return True if re.match(r"^/[^/]", urlstring) else False
|
|
510
|
+
|
|
511
|
+
@classmethod
|
|
512
|
+
def is_url(cls, urlstring):
|
|
513
|
+
"""Returns True if urlstring is an actual full or relative url
|
|
514
|
+
|
|
515
|
+
Moved from bang.utils.Url.match on 1-10-2023
|
|
516
|
+
"""
|
|
517
|
+
# REGEX = re.compile(r"^(?:https?:\/\/|\/\/)", re.I)
|
|
518
|
+
return cls.is_full_url(urlstring) or cls.is_relative_url(urlstring)
|
|
519
|
+
|
|
520
|
+
@classmethod
|
|
521
|
+
def create_instance(cls, *args, **kwargs):
|
|
522
|
+
#return type(self)(*args, **kwargs)
|
|
523
|
+
return Url(*args, **kwargs)
|
|
524
|
+
|
|
525
|
+
def __new__(cls, urlstring=None, *args, **kwargs):
|
|
526
|
+
"""
|
|
527
|
+
:keyword hostname: Optional[str] the HOST:PORT or just HOST
|
|
528
|
+
"""
|
|
529
|
+
parts = cls.merge(urlstring, *args, **kwargs)
|
|
530
|
+
urlstring = parts.pop("urlstring")
|
|
531
|
+
instance = super().__new__(cls, urlstring)
|
|
532
|
+
for k, v in parts.items():
|
|
533
|
+
setattr(instance, k, v)
|
|
534
|
+
return instance
|
|
535
|
+
|
|
536
|
+
def create(self, *args, **kwargs):
|
|
537
|
+
return self.create_instance(*args, **kwargs)
|
|
538
|
+
|
|
539
|
+
def add(self, **kwargs):
|
|
540
|
+
"""Just a shortcut to change the current url, equivalent to
|
|
541
|
+
Url(self, **kwargs)
|
|
542
|
+
"""
|
|
543
|
+
if "path" in kwargs:
|
|
544
|
+
path = kwargs["path"]
|
|
545
|
+
if isinstance(path, bytes):
|
|
546
|
+
path = String(path)
|
|
547
|
+
if not path[0].startswith("/"):
|
|
548
|
+
paths = self.normalize_paths(self.path, path)
|
|
549
|
+
else:
|
|
550
|
+
paths = self.normalize_paths(path)
|
|
551
|
+
kwargs["path"] = "/".join(paths)
|
|
552
|
+
return self.create(self, **kwargs)
|
|
553
|
+
|
|
554
|
+
def subtract(self, *paths, **kwargs):
|
|
555
|
+
sub_kwargs = self.jsonable()
|
|
556
|
+
|
|
557
|
+
path2 = self.normalize_paths(paths)
|
|
558
|
+
path2.extend(self.normalize_paths(kwargs.pop("path", "")))
|
|
559
|
+
if path2:
|
|
560
|
+
sub_path = self.normalize_paths(self.path)
|
|
561
|
+
for p in path2:
|
|
562
|
+
try:
|
|
563
|
+
sub_path.remove(p)
|
|
564
|
+
except ValueError:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
sub_kwargs["path"] = sub_path
|
|
568
|
+
|
|
569
|
+
for k, v in kwargs.items():
|
|
570
|
+
if k == "query_kwargs":
|
|
571
|
+
for qk, qv in kwargs[k].items():
|
|
572
|
+
if str(sub_kwargs[k][qk]) == str(qv):
|
|
573
|
+
sub_kwargs[k].pop(qk)
|
|
574
|
+
|
|
575
|
+
else:
|
|
576
|
+
if str(sub_kwargs[k]) == str(v):
|
|
577
|
+
sub_kwargs.pop(k)
|
|
578
|
+
|
|
579
|
+
return self.create(**sub_kwargs)
|
|
580
|
+
|
|
581
|
+
def _normalize_params(self, *paths, **query_kwargs):
|
|
582
|
+
"""a lot of the helper methods are very similar, this handles their
|
|
583
|
+
arguments"""
|
|
584
|
+
kwargs = {}
|
|
585
|
+
|
|
586
|
+
if paths:
|
|
587
|
+
fragment = String(paths[-1])
|
|
588
|
+
if fragment:
|
|
589
|
+
if fragment.startswith("#"):
|
|
590
|
+
kwargs["fragment"] = fragment
|
|
591
|
+
paths.pop(-1)
|
|
592
|
+
|
|
593
|
+
kwargs["path"] = "/".join(self.normalize_paths(*paths))
|
|
594
|
+
|
|
595
|
+
kwargs["query_kwargs"] = query_kwargs
|
|
596
|
+
return kwargs
|
|
597
|
+
|
|
598
|
+
def parent(self, *paths, **query_kwargs):
|
|
599
|
+
"""create a new Url instance one level up from the current Url instance
|
|
600
|
+
|
|
601
|
+
so if self contains /foo/bar then self.parent() would return /foo
|
|
602
|
+
|
|
603
|
+
:param *paths: list, the paths to append to the parent path
|
|
604
|
+
:param **query_kwargs: dict, any query string params to add
|
|
605
|
+
:returns: new Url instance
|
|
606
|
+
"""
|
|
607
|
+
kwargs = self._normalize_params(*paths, **query_kwargs)
|
|
608
|
+
path_args = self.path.split("/")
|
|
609
|
+
if path_args:
|
|
610
|
+
urlstring = self.subtract(path_args[-1])
|
|
611
|
+
else:
|
|
612
|
+
urlstring = self
|
|
613
|
+
|
|
614
|
+
return urlstring.add(**kwargs)
|
|
615
|
+
|
|
616
|
+
def base(self, *paths, **query_kwargs):
|
|
617
|
+
"""create a new url object using the current base path as a base
|
|
618
|
+
|
|
619
|
+
if you had requested /foo/bar, then this would append *paths and
|
|
620
|
+
**query_kwargs to /foo/bar
|
|
621
|
+
|
|
622
|
+
:example:
|
|
623
|
+
# current path: /foo/bar
|
|
624
|
+
|
|
625
|
+
print url # http://host.com/foo/bar
|
|
626
|
+
|
|
627
|
+
print url.base() # http://host.com/foo/bar
|
|
628
|
+
print url.base("che", boom="bam") # http://host/foo/bar/che?boom=bam
|
|
629
|
+
|
|
630
|
+
:param *paths: list, the paths to append to the current path without
|
|
631
|
+
query params
|
|
632
|
+
:param **query_kwargs: dict, any query string params to add
|
|
633
|
+
"""
|
|
634
|
+
kwargs = self._normalize_params(*paths, **query_kwargs)
|
|
635
|
+
if self.path:
|
|
636
|
+
if "path" in kwargs:
|
|
637
|
+
paths = self.normalize_paths(self.path, kwargs["path"])
|
|
638
|
+
kwargs["path"] = "/".join(paths)
|
|
639
|
+
else:
|
|
640
|
+
kwargs["path"] = self.path
|
|
641
|
+
return self.create(self.root, **kwargs)
|
|
642
|
+
|
|
643
|
+
def host(self, *paths, **query_kwargs):
|
|
644
|
+
"""create a new url object using the host as a base
|
|
645
|
+
|
|
646
|
+
if you had requested http://host/foo/bar, then this would append *paths
|
|
647
|
+
and **query_kwargs to http://host
|
|
648
|
+
|
|
649
|
+
:example:
|
|
650
|
+
# current url: http://host/foo/bar
|
|
651
|
+
|
|
652
|
+
print(url) # http://host.com/foo/bar
|
|
653
|
+
|
|
654
|
+
print(url.host()) # http://host.com/
|
|
655
|
+
print(url.host("che", boom="bam")) # http://host/che?boom=bam
|
|
656
|
+
|
|
657
|
+
:param *paths: list, the path parts to append to the current host
|
|
658
|
+
:param **query_kwargs: dict, any query string params to add
|
|
659
|
+
"""
|
|
660
|
+
kwargs = self._normalize_params(*paths, **query_kwargs)
|
|
661
|
+
|
|
662
|
+
# because path is from host then it is automatically absolute
|
|
663
|
+
if "path" in kwargs and not kwargs["path"].startswith("/"):
|
|
664
|
+
kwargs["path"] = "/" + kwargs["path"]
|
|
665
|
+
|
|
666
|
+
return self.create(self.root, **kwargs)
|
|
667
|
+
|
|
668
|
+
def child(self, *paths, **kwargs):
|
|
669
|
+
"""Layers paths and kwargs on top of self, using passed in values to
|
|
670
|
+
replace anything in self
|
|
671
|
+
|
|
672
|
+
:Example:
|
|
673
|
+
u = Url("foo", 1)
|
|
674
|
+
print(u) # foo/1
|
|
675
|
+
|
|
676
|
+
u2 = u.child("bar", 2)
|
|
677
|
+
print(u2) # foo/1/bar/2
|
|
678
|
+
|
|
679
|
+
u3 = u.child(path=["bar", 2])
|
|
680
|
+
print(u2) # bar/2
|
|
681
|
+
|
|
682
|
+
:param *paths: url path parts
|
|
683
|
+
:param **kwargs: same kwargs that can be passed into creation
|
|
684
|
+
:returns: a new instance of this class
|
|
685
|
+
"""
|
|
686
|
+
return self.create(self, *paths, **kwargs)
|
|
687
|
+
|
|
688
|
+
def jsonable(self):
|
|
689
|
+
return dict(
|
|
690
|
+
scheme=self.scheme,
|
|
691
|
+
username=self.username,
|
|
692
|
+
password=self.password,
|
|
693
|
+
hostname=self.hostname,
|
|
694
|
+
port=self.port,
|
|
695
|
+
path=self.path,
|
|
696
|
+
query_kwargs=self.query_kwargs,
|
|
697
|
+
fragment=self.fragment,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def copy(self):
|
|
701
|
+
return self.__deepcopy__()
|
|
702
|
+
|
|
703
|
+
def __copy__(self):
|
|
704
|
+
return self.__deepcopy__()
|
|
705
|
+
|
|
706
|
+
def __deepcopy__(self, memodict={}):
|
|
707
|
+
return self.create(**self.jsonable())
|
|
708
|
+
# scheme=self.scheme,
|
|
709
|
+
# username=self.username,
|
|
710
|
+
# password=self.password,
|
|
711
|
+
# hostname=self.hostname,
|
|
712
|
+
# port=self.port,
|
|
713
|
+
# path=self.path,
|
|
714
|
+
# query_kwargs=self.query_kwargs,
|
|
715
|
+
# fragment=self.fragment,
|
|
716
|
+
# )
|
|
717
|
+
|
|
718
|
+
def __add__(self, other):
|
|
719
|
+
ret = ""
|
|
720
|
+
if isinstance(other, Mapping):
|
|
721
|
+
ret = self.add(query_kwargs=other)
|
|
722
|
+
|
|
723
|
+
elif isinstance(other, MutableSequence):
|
|
724
|
+
ret = self.add(path=other)
|
|
725
|
+
|
|
726
|
+
elif isinstance(other, basestring):
|
|
727
|
+
ret = self.add(path=other)
|
|
728
|
+
|
|
729
|
+
elif isinstance(other, Sequence):
|
|
730
|
+
ret = self.add(path=other)
|
|
731
|
+
|
|
732
|
+
else:
|
|
733
|
+
raise ValueError("Not sure how to add {}".format(type(other)))
|
|
734
|
+
|
|
735
|
+
return ret
|
|
736
|
+
__iadd__ = __add__
|
|
737
|
+
|
|
738
|
+
def __truediv__(self, other):
|
|
739
|
+
ret = ""
|
|
740
|
+
if isinstance(other, MutableSequence):
|
|
741
|
+
ret = self.add(path=other)
|
|
742
|
+
|
|
743
|
+
elif isinstance(other, basestring):
|
|
744
|
+
ret = self.add(path=other)
|
|
745
|
+
|
|
746
|
+
elif isinstance(other, Sequence):
|
|
747
|
+
ret = self.add(path=other)
|
|
748
|
+
|
|
749
|
+
else:
|
|
750
|
+
raise ValueError("Not sure how to add {}".format(type(other)))
|
|
751
|
+
|
|
752
|
+
return ret
|
|
753
|
+
__itruediv__ = __truediv__
|
|
754
|
+
|
|
755
|
+
def __sub__(self, other):
|
|
756
|
+
"""Return a new url with other removed"""
|
|
757
|
+
ret = ""
|
|
758
|
+
if isinstance(other, Mapping):
|
|
759
|
+
ret = self.subtract(query_kwargs=other)
|
|
760
|
+
|
|
761
|
+
elif isinstance(other, MutableSequence):
|
|
762
|
+
ret = self.subtract(path=other)
|
|
763
|
+
|
|
764
|
+
elif isinstance(other, basestring):
|
|
765
|
+
ret = self.subtract(path=other)
|
|
766
|
+
|
|
767
|
+
elif isinstance(other, Sequence):
|
|
768
|
+
ret = self.subtract(path=other)
|
|
769
|
+
|
|
770
|
+
else:
|
|
771
|
+
raise ValueError("Not sure how to add {}".format(type(other)))
|
|
772
|
+
|
|
773
|
+
return ret
|
|
774
|
+
__isub__ = __sub__
|
|
775
|
+
|
|
776
|
+
def jsonable(self):
|
|
777
|
+
ret = {}
|
|
778
|
+
for k in self.keys():
|
|
779
|
+
v = getattr(self, k)
|
|
780
|
+
if k == "query_kwargs":
|
|
781
|
+
ret[k] = dict(v)
|
|
782
|
+
else:
|
|
783
|
+
ret[k] = v
|
|
784
|
+
|
|
785
|
+
return ret
|
|
786
|
+
|
|
787
|
+
def has_hostname(self):
|
|
788
|
+
"""Return True if this url has a host"""
|
|
789
|
+
return True if self.hostname else False
|
|
790
|
+
|
|
791
|
+
def is_hostname(self, hostname):
|
|
792
|
+
"""return true if the url's host matches host"""
|
|
793
|
+
ret = False
|
|
794
|
+
if not self.has_hostname() and not hostname:
|
|
795
|
+
ret = True
|
|
796
|
+
else:
|
|
797
|
+
ret = self.hostname.lower() == hostname.lower()
|
|
798
|
+
return ret
|
|
799
|
+
|
|
800
|
+
def is_relative(self):
|
|
801
|
+
"""return True if this url is a path or relative url
|
|
802
|
+
|
|
803
|
+
a relative url: //hostname.tld/foo/bar
|
|
804
|
+
"""
|
|
805
|
+
return self.is_relative_url(self)
|
|
806
|
+
|
|
807
|
+
def is_full(self):
|
|
808
|
+
"""return True if this url is a path or relative url
|
|
809
|
+
|
|
810
|
+
a full url: scheme//hostname.tld/foo/bar
|
|
811
|
+
"""
|
|
812
|
+
return self.is_full_url(self)
|
|
813
|
+
|
|
814
|
+
def is_path(self):
|
|
815
|
+
"""Return true if this is a path url (url that doesn't have a host)
|
|
816
|
+
|
|
817
|
+
a path url: /foo/bar
|
|
818
|
+
"""
|
|
819
|
+
return self.is_path_url(self)
|
|
820
|
+
|
|
821
|
+
def is_local(self):
|
|
822
|
+
"""return True if is a localhost url"""
|
|
823
|
+
return (
|
|
824
|
+
self.is_path()
|
|
825
|
+
or self.is_hostname("localhost")
|
|
826
|
+
or self.is_hostname("127.0.0.1")
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def unsplit(self):
|
|
830
|
+
"""By default, the URL won't contain a scheme if it wasn't passed in,
|
|
831
|
+
this will add the default scheme and return the url"""
|
|
832
|
+
return parse.urlunsplit((
|
|
833
|
+
self.scheme,
|
|
834
|
+
self.netloc,
|
|
835
|
+
self.path,
|
|
836
|
+
self.query,
|
|
837
|
+
self.fragment,
|
|
838
|
+
))
|
|
839
|
+
|
|
840
|
+
def full(self):
|
|
841
|
+
"""alias of .unsplit()"""
|
|
842
|
+
return self.unsplit()
|
|
843
|
+
Urlstring = Url
|
|
844
|
+
UrlString = Url
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class Host(tuple):
|
|
848
|
+
"""Creates a tuple (hostname, port) that can be passed to built-in Python
|
|
849
|
+
server classes and anything that uses that same interface, does all the
|
|
850
|
+
lifting to figure out what the hostname and port should be based on the
|
|
851
|
+
passed in input, so you can pass things like hostname:port, or (host,
|
|
852
|
+
port), etc.
|
|
853
|
+
|
|
854
|
+
https://docs.python.org/3/library/socket.html#module-socket - creates an
|
|
855
|
+
AF_INET server address
|
|
856
|
+
"""
|
|
857
|
+
@property
|
|
858
|
+
def hostname(self):
|
|
859
|
+
"""Return just the hostname with no port or scheme or anything
|
|
860
|
+
|
|
861
|
+
:returns: string
|
|
862
|
+
"""
|
|
863
|
+
return self[0]
|
|
864
|
+
|
|
865
|
+
@property
|
|
866
|
+
def port(self):
|
|
867
|
+
"""return just the port
|
|
868
|
+
|
|
869
|
+
:returns: int
|
|
870
|
+
"""
|
|
871
|
+
return self[1]
|
|
872
|
+
|
|
873
|
+
@property
|
|
874
|
+
def hostloc(self):
|
|
875
|
+
"""Returns hostname:port
|
|
876
|
+
|
|
877
|
+
:returns: string
|
|
878
|
+
"""
|
|
879
|
+
return self.url.hostloc
|
|
880
|
+
|
|
881
|
+
def __new__(cls, host="", port=None):
|
|
882
|
+
"""Create a new server_address tuple
|
|
883
|
+
|
|
884
|
+
If nothing is passed in the default address is ("", 0) which in most
|
|
885
|
+
python stdlib server code will bind to localhost with some random
|
|
886
|
+
free port
|
|
887
|
+
|
|
888
|
+
:param host: str|tuple[str, int|str], can handle things strings like
|
|
889
|
+
":<PORT>", "<HOSTNAME>:<PORT>", "<HOSTNAME>", or
|
|
890
|
+
["<HOSTNAME>", "<PORT>"]
|
|
891
|
+
:param port: int
|
|
892
|
+
"""
|
|
893
|
+
if isinstance(host, tuple):
|
|
894
|
+
# compensate for passing in a server_address tuple which I seem
|
|
895
|
+
# to do a lot evidently
|
|
896
|
+
port = host[1]
|
|
897
|
+
host = host[0]
|
|
898
|
+
|
|
899
|
+
if host.startswith(":"):
|
|
900
|
+
# host is just a port like ":8000"
|
|
901
|
+
port = host[1:]
|
|
902
|
+
host = ""
|
|
903
|
+
|
|
904
|
+
u = Url(host, default_port=port)
|
|
905
|
+
|
|
906
|
+
port = u.port
|
|
907
|
+
if not port:
|
|
908
|
+
# If the port is empty we set it to 0 so it can trigger python's
|
|
909
|
+
# internal server code to find an available port. If we didn't do
|
|
910
|
+
# this we would get the error: TypeError: 'NoneType' object cannot
|
|
911
|
+
# be interpreted as an integer
|
|
912
|
+
port = 0
|
|
913
|
+
|
|
914
|
+
instance = super().__new__(cls, [u.hostname, port])
|
|
915
|
+
instance.url = u
|
|
916
|
+
return instance
|
|
917
|
+
|
|
918
|
+
def __str__(self):
|
|
919
|
+
return self.hostloc
|
|
920
|
+
|
|
921
|
+
def __bytes__(self):
|
|
922
|
+
return ByteString(self.hostloc)
|
|
923
|
+
|
|
924
|
+
def full(self):
|
|
925
|
+
return self.url.full()
|
|
926
|
+
|
|
927
|
+
def client(self):
|
|
928
|
+
"""Url can technically hold a hostname like 0.0.0.0, this will
|
|
929
|
+
compensate for that, useful for test clients
|
|
930
|
+
|
|
931
|
+
:returns: str, a netloc (eg "host:port") that a client can use to make
|
|
932
|
+
a request
|
|
933
|
+
"""
|
|
934
|
+
domain, port = self
|
|
935
|
+
|
|
936
|
+
netloc = domain
|
|
937
|
+
|
|
938
|
+
if domain == "0.0.0.0":
|
|
939
|
+
netloc = gethostname()
|
|
940
|
+
|
|
941
|
+
elif not domain:
|
|
942
|
+
netloc = "127.0.0.1"
|
|
943
|
+
|
|
944
|
+
if port:
|
|
945
|
+
netloc += ":{}".format(port)
|
|
946
|
+
|
|
947
|
+
return netloc
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
class SlugWords(StopWordTokenizer):
|
|
951
|
+
"""Internal class used by Slug to find all the valid slug words"""
|
|
952
|
+
|
|
953
|
+
NON_ASCII_REGEX = re.compile(r'[^\x00-\x7F]+')
|
|
954
|
+
"""non-ascii characters
|
|
955
|
+
|
|
956
|
+
https://stackoverflow.com/a/2759009/5006
|
|
957
|
+
"""
|
|
958
|
+
|
|
959
|
+
def normalize(self, token):
|
|
960
|
+
word = token.text
|
|
961
|
+
word = self.NON_ASCII_REGEX.sub("", word)
|
|
962
|
+
return word.lower()
|
|
963
|
+
|
|
964
|
+
def is_valid(self, word):
|
|
965
|
+
return word and (word not in self.STOP_WORDS)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
class Slug(String):
|
|
969
|
+
"""Given a string, convert it into a slug that can be used in a url path
|
|
970
|
+
|
|
971
|
+
This tokenizes the passed in string, strips non-ascii, punctuation, and
|
|
972
|
+
stop words and then returns the remaining words joined by delim (default is
|
|
973
|
+
a hyphen)
|
|
974
|
+
"""
|
|
975
|
+
def __new__(cls, slug, **kwargs):
|
|
976
|
+
"""
|
|
977
|
+
:param val: str, the prospective slug
|
|
978
|
+
:keyword delim: str, default is a hyphen
|
|
979
|
+
:keyword size: int, the max size you want the slug
|
|
980
|
+
"""
|
|
981
|
+
delim = kwargs.get("delim", "-")
|
|
982
|
+
words = SlugWords(slug)
|
|
983
|
+
slug = delim.join(words)
|
|
984
|
+
if size := kwargs.get("size", 0):
|
|
985
|
+
slug = String(slug).truncate(size, sep=delim)
|
|
986
|
+
|
|
987
|
+
return super().__new__(cls, slug, **kwargs)
|
|
988
|
+
|