plain 0.1.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.
- plain/README.md +33 -0
- plain/__main__.py +5 -0
- plain/assets/README.md +56 -0
- plain/assets/__init__.py +6 -0
- plain/assets/finders.py +233 -0
- plain/assets/preflight.py +14 -0
- plain/assets/storage.py +916 -0
- plain/assets/utils.py +52 -0
- plain/assets/whitenoise/__init__.py +5 -0
- plain/assets/whitenoise/base.py +259 -0
- plain/assets/whitenoise/compress.py +189 -0
- plain/assets/whitenoise/media_types.py +137 -0
- plain/assets/whitenoise/middleware.py +197 -0
- plain/assets/whitenoise/responders.py +286 -0
- plain/assets/whitenoise/storage.py +178 -0
- plain/assets/whitenoise/string_utils.py +13 -0
- plain/cli/README.md +123 -0
- plain/cli/__init__.py +3 -0
- plain/cli/cli.py +439 -0
- plain/cli/formatting.py +61 -0
- plain/cli/packages.py +73 -0
- plain/cli/print.py +9 -0
- plain/cli/startup.py +33 -0
- plain/csrf/README.md +3 -0
- plain/csrf/middleware.py +466 -0
- plain/csrf/views.py +10 -0
- plain/debug.py +23 -0
- plain/exceptions.py +242 -0
- plain/forms/README.md +14 -0
- plain/forms/__init__.py +8 -0
- plain/forms/boundfield.py +58 -0
- plain/forms/exceptions.py +11 -0
- plain/forms/fields.py +1030 -0
- plain/forms/forms.py +297 -0
- plain/http/README.md +1 -0
- plain/http/__init__.py +51 -0
- plain/http/cookie.py +20 -0
- plain/http/multipartparser.py +743 -0
- plain/http/request.py +754 -0
- plain/http/response.py +719 -0
- plain/internal/__init__.py +0 -0
- plain/internal/files/README.md +3 -0
- plain/internal/files/__init__.py +3 -0
- plain/internal/files/base.py +161 -0
- plain/internal/files/locks.py +127 -0
- plain/internal/files/move.py +102 -0
- plain/internal/files/temp.py +79 -0
- plain/internal/files/uploadedfile.py +150 -0
- plain/internal/files/uploadhandler.py +254 -0
- plain/internal/files/utils.py +78 -0
- plain/internal/handlers/__init__.py +0 -0
- plain/internal/handlers/base.py +133 -0
- plain/internal/handlers/exception.py +145 -0
- plain/internal/handlers/wsgi.py +216 -0
- plain/internal/legacy/__init__.py +0 -0
- plain/internal/legacy/__main__.py +12 -0
- plain/internal/legacy/management/__init__.py +414 -0
- plain/internal/legacy/management/base.py +692 -0
- plain/internal/legacy/management/color.py +113 -0
- plain/internal/legacy/management/commands/__init__.py +0 -0
- plain/internal/legacy/management/commands/collectstatic.py +297 -0
- plain/internal/legacy/management/sql.py +67 -0
- plain/internal/legacy/management/utils.py +175 -0
- plain/json.py +40 -0
- plain/logs/README.md +24 -0
- plain/logs/__init__.py +5 -0
- plain/logs/configure.py +39 -0
- plain/logs/loggers.py +74 -0
- plain/logs/utils.py +46 -0
- plain/middleware/README.md +3 -0
- plain/middleware/__init__.py +0 -0
- plain/middleware/clickjacking.py +52 -0
- plain/middleware/common.py +87 -0
- plain/middleware/gzip.py +64 -0
- plain/middleware/security.py +64 -0
- plain/packages/README.md +41 -0
- plain/packages/__init__.py +4 -0
- plain/packages/config.py +259 -0
- plain/packages/registry.py +438 -0
- plain/paginator.py +187 -0
- plain/preflight/README.md +3 -0
- plain/preflight/__init__.py +38 -0
- plain/preflight/compatibility/__init__.py +0 -0
- plain/preflight/compatibility/django_4_0.py +20 -0
- plain/preflight/files.py +19 -0
- plain/preflight/messages.py +88 -0
- plain/preflight/registry.py +72 -0
- plain/preflight/security/__init__.py +0 -0
- plain/preflight/security/base.py +268 -0
- plain/preflight/security/csrf.py +40 -0
- plain/preflight/urls.py +117 -0
- plain/runtime/README.md +75 -0
- plain/runtime/__init__.py +61 -0
- plain/runtime/global_settings.py +199 -0
- plain/runtime/user_settings.py +353 -0
- plain/signals/README.md +14 -0
- plain/signals/__init__.py +5 -0
- plain/signals/dispatch/__init__.py +9 -0
- plain/signals/dispatch/dispatcher.py +320 -0
- plain/signals/dispatch/license.txt +35 -0
- plain/signing.py +299 -0
- plain/templates/README.md +20 -0
- plain/templates/__init__.py +6 -0
- plain/templates/core.py +24 -0
- plain/templates/jinja/README.md +227 -0
- plain/templates/jinja/__init__.py +22 -0
- plain/templates/jinja/defaults.py +119 -0
- plain/templates/jinja/extensions.py +39 -0
- plain/templates/jinja/filters.py +28 -0
- plain/templates/jinja/globals.py +19 -0
- plain/test/README.md +3 -0
- plain/test/__init__.py +16 -0
- plain/test/client.py +985 -0
- plain/test/utils.py +255 -0
- plain/urls/README.md +3 -0
- plain/urls/__init__.py +40 -0
- plain/urls/base.py +118 -0
- plain/urls/conf.py +94 -0
- plain/urls/converters.py +66 -0
- plain/urls/exceptions.py +9 -0
- plain/urls/resolvers.py +731 -0
- plain/utils/README.md +3 -0
- plain/utils/__init__.py +0 -0
- plain/utils/_os.py +52 -0
- plain/utils/cache.py +327 -0
- plain/utils/connection.py +84 -0
- plain/utils/crypto.py +76 -0
- plain/utils/datastructures.py +345 -0
- plain/utils/dateformat.py +329 -0
- plain/utils/dateparse.py +154 -0
- plain/utils/dates.py +76 -0
- plain/utils/deconstruct.py +54 -0
- plain/utils/decorators.py +90 -0
- plain/utils/deprecation.py +6 -0
- plain/utils/duration.py +44 -0
- plain/utils/email.py +12 -0
- plain/utils/encoding.py +235 -0
- plain/utils/functional.py +456 -0
- plain/utils/hashable.py +26 -0
- plain/utils/html.py +401 -0
- plain/utils/http.py +374 -0
- plain/utils/inspect.py +73 -0
- plain/utils/ipv6.py +46 -0
- plain/utils/itercompat.py +8 -0
- plain/utils/module_loading.py +69 -0
- plain/utils/regex_helper.py +353 -0
- plain/utils/safestring.py +72 -0
- plain/utils/termcolors.py +221 -0
- plain/utils/text.py +518 -0
- plain/utils/timesince.py +138 -0
- plain/utils/timezone.py +244 -0
- plain/utils/tree.py +126 -0
- plain/validators.py +603 -0
- plain/views/README.md +268 -0
- plain/views/__init__.py +18 -0
- plain/views/base.py +107 -0
- plain/views/csrf.py +24 -0
- plain/views/errors.py +25 -0
- plain/views/exceptions.py +4 -0
- plain/views/forms.py +76 -0
- plain/views/objects.py +229 -0
- plain/views/redirect.py +72 -0
- plain/views/templates.py +66 -0
- plain/wsgi.py +11 -0
- plain-0.1.0.dist-info/LICENSE +85 -0
- plain-0.1.0.dist-info/METADATA +51 -0
- plain-0.1.0.dist-info/RECORD +169 -0
- plain-0.1.0.dist-info/WHEEL +4 -0
- plain-0.1.0.dist-info/entry_points.txt +3 -0
plain/utils/dateparse.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Functions to parse datetime objects."""
|
|
2
|
+
|
|
3
|
+
# We're using regular expressions rather than time.strptime because:
|
|
4
|
+
# - They provide both validation and parsing.
|
|
5
|
+
# - They're more flexible for datetimes.
|
|
6
|
+
# - The date/datetime/time constructors produce friendlier error messages.
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
|
|
10
|
+
from plain.utils.regex_helper import _lazy_re_compile
|
|
11
|
+
from plain.utils.timezone import get_fixed_timezone
|
|
12
|
+
|
|
13
|
+
date_re = _lazy_re_compile(r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})$")
|
|
14
|
+
|
|
15
|
+
time_re = _lazy_re_compile(
|
|
16
|
+
r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
|
|
17
|
+
r"(?::(?P<second>\d{1,2})(?:[\.,](?P<microsecond>\d{1,6})\d{0,6})?)?$"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
datetime_re = _lazy_re_compile(
|
|
21
|
+
r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
|
|
22
|
+
r"[T ](?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
|
|
23
|
+
r"(?::(?P<second>\d{1,2})(?:[\.,](?P<microsecond>\d{1,6})\d{0,6})?)?"
|
|
24
|
+
r"\s*(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
standard_duration_re = _lazy_re_compile(
|
|
28
|
+
r"^"
|
|
29
|
+
r"(?:(?P<days>-?\d+) (days?, )?)?"
|
|
30
|
+
r"(?P<sign>-?)"
|
|
31
|
+
r"((?:(?P<hours>\d+):)(?=\d+:\d+))?"
|
|
32
|
+
r"(?:(?P<minutes>\d+):)?"
|
|
33
|
+
r"(?P<seconds>\d+)"
|
|
34
|
+
r"(?:[\.,](?P<microseconds>\d{1,6})\d{0,6})?"
|
|
35
|
+
r"$"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Support the sections of ISO 8601 date representation that are accepted by
|
|
39
|
+
# timedelta
|
|
40
|
+
iso8601_duration_re = _lazy_re_compile(
|
|
41
|
+
r"^(?P<sign>[-+]?)"
|
|
42
|
+
r"P"
|
|
43
|
+
r"(?:(?P<days>\d+([\.,]\d+)?)D)?"
|
|
44
|
+
r"(?:T"
|
|
45
|
+
r"(?:(?P<hours>\d+([\.,]\d+)?)H)?"
|
|
46
|
+
r"(?:(?P<minutes>\d+([\.,]\d+)?)M)?"
|
|
47
|
+
r"(?:(?P<seconds>\d+([\.,]\d+)?)S)?"
|
|
48
|
+
r")?"
|
|
49
|
+
r"$"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Support PostgreSQL's day-time interval format, e.g. "3 days 04:05:06". The
|
|
53
|
+
# year-month and mixed intervals cannot be converted to a timedelta and thus
|
|
54
|
+
# aren't accepted.
|
|
55
|
+
postgres_interval_re = _lazy_re_compile(
|
|
56
|
+
r"^"
|
|
57
|
+
r"(?:(?P<days>-?\d+) (days? ?))?"
|
|
58
|
+
r"(?:(?P<sign>[-+])?"
|
|
59
|
+
r"(?P<hours>\d+):"
|
|
60
|
+
r"(?P<minutes>\d\d):"
|
|
61
|
+
r"(?P<seconds>\d\d)"
|
|
62
|
+
r"(?:\.(?P<microseconds>\d{1,6}))?"
|
|
63
|
+
r")?$"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_date(value):
|
|
68
|
+
"""Parse a string and return a datetime.date.
|
|
69
|
+
|
|
70
|
+
Raise ValueError if the input is well formatted but not a valid date.
|
|
71
|
+
Return None if the input isn't well formatted.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
return datetime.date.fromisoformat(value)
|
|
75
|
+
except ValueError:
|
|
76
|
+
if match := date_re.match(value):
|
|
77
|
+
kw = {k: int(v) for k, v in match.groupdict().items()}
|
|
78
|
+
return datetime.date(**kw)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_time(value):
|
|
82
|
+
"""Parse a string and return a datetime.time.
|
|
83
|
+
|
|
84
|
+
This function doesn't support time zone offsets.
|
|
85
|
+
|
|
86
|
+
Raise ValueError if the input is well formatted but not a valid time.
|
|
87
|
+
Return None if the input isn't well formatted, in particular if it
|
|
88
|
+
contains an offset.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
# The fromisoformat() method takes time zone info into account and
|
|
92
|
+
# returns a time with a tzinfo component, if possible. However, there
|
|
93
|
+
# are no circumstances where aware datetime.time objects make sense, so
|
|
94
|
+
# remove the time zone offset.
|
|
95
|
+
return datetime.time.fromisoformat(value).replace(tzinfo=None)
|
|
96
|
+
except ValueError:
|
|
97
|
+
if match := time_re.match(value):
|
|
98
|
+
kw = match.groupdict()
|
|
99
|
+
kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
|
|
100
|
+
kw = {k: int(v) for k, v in kw.items() if v is not None}
|
|
101
|
+
return datetime.time(**kw)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_datetime(value):
|
|
105
|
+
"""Parse a string and return a datetime.datetime.
|
|
106
|
+
|
|
107
|
+
This function supports time zone offsets. When the input contains one,
|
|
108
|
+
the output uses a timezone with a fixed offset from UTC.
|
|
109
|
+
|
|
110
|
+
Raise ValueError if the input is well formatted but not a valid datetime.
|
|
111
|
+
Return None if the input isn't well formatted.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
return datetime.datetime.fromisoformat(value)
|
|
115
|
+
except ValueError:
|
|
116
|
+
if match := datetime_re.match(value):
|
|
117
|
+
kw = match.groupdict()
|
|
118
|
+
kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
|
|
119
|
+
tzinfo = kw.pop("tzinfo")
|
|
120
|
+
if tzinfo == "Z":
|
|
121
|
+
tzinfo = datetime.timezone.utc
|
|
122
|
+
elif tzinfo is not None:
|
|
123
|
+
offset_mins = int(tzinfo[-2:]) if len(tzinfo) > 3 else 0
|
|
124
|
+
offset = 60 * int(tzinfo[1:3]) + offset_mins
|
|
125
|
+
if tzinfo[0] == "-":
|
|
126
|
+
offset = -offset
|
|
127
|
+
tzinfo = get_fixed_timezone(offset)
|
|
128
|
+
kw = {k: int(v) for k, v in kw.items() if v is not None}
|
|
129
|
+
return datetime.datetime(**kw, tzinfo=tzinfo)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_duration(value):
|
|
133
|
+
"""Parse a duration string and return a datetime.timedelta.
|
|
134
|
+
|
|
135
|
+
The preferred format for durations in Plain is '%d %H:%M:%S.%f'.
|
|
136
|
+
|
|
137
|
+
Also supports ISO 8601 representation and PostgreSQL's day-time interval
|
|
138
|
+
format.
|
|
139
|
+
"""
|
|
140
|
+
match = (
|
|
141
|
+
standard_duration_re.match(value)
|
|
142
|
+
or iso8601_duration_re.match(value)
|
|
143
|
+
or postgres_interval_re.match(value)
|
|
144
|
+
)
|
|
145
|
+
if match:
|
|
146
|
+
kw = match.groupdict()
|
|
147
|
+
sign = -1 if kw.pop("sign", "+") == "-" else 1
|
|
148
|
+
if kw.get("microseconds"):
|
|
149
|
+
kw["microseconds"] = kw["microseconds"].ljust(6, "0")
|
|
150
|
+
kw = {k: float(v.replace(",", ".")) for k, v in kw.items() if v is not None}
|
|
151
|
+
days = datetime.timedelta(kw.pop("days", 0.0) or 0.0)
|
|
152
|
+
if match.re == iso8601_duration_re:
|
|
153
|
+
days *= sign
|
|
154
|
+
return days + sign * datetime.timedelta(**kw)
|
plain/utils/dates.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"Commonly-used date structures"
|
|
2
|
+
|
|
3
|
+
WEEKDAYS = {
|
|
4
|
+
0: "Monday",
|
|
5
|
+
1: "Tuesday",
|
|
6
|
+
2: "Wednesday",
|
|
7
|
+
3: "Thursday",
|
|
8
|
+
4: "Friday",
|
|
9
|
+
5: "Saturday",
|
|
10
|
+
6: "Sunday",
|
|
11
|
+
}
|
|
12
|
+
WEEKDAYS_ABBR = {
|
|
13
|
+
0: "Mon",
|
|
14
|
+
1: "Tue",
|
|
15
|
+
2: "Wed",
|
|
16
|
+
3: "Thu",
|
|
17
|
+
4: "Fri",
|
|
18
|
+
5: "Sat",
|
|
19
|
+
6: "Sun",
|
|
20
|
+
}
|
|
21
|
+
MONTHS = {
|
|
22
|
+
1: "January",
|
|
23
|
+
2: "February",
|
|
24
|
+
3: "March",
|
|
25
|
+
4: "April",
|
|
26
|
+
5: "May",
|
|
27
|
+
6: "June",
|
|
28
|
+
7: "July",
|
|
29
|
+
8: "August",
|
|
30
|
+
9: "September",
|
|
31
|
+
10: "October",
|
|
32
|
+
11: "November",
|
|
33
|
+
12: "December",
|
|
34
|
+
}
|
|
35
|
+
MONTHS_3 = {
|
|
36
|
+
1: "jan",
|
|
37
|
+
2: "feb",
|
|
38
|
+
3: "mar",
|
|
39
|
+
4: "apr",
|
|
40
|
+
5: "may",
|
|
41
|
+
6: "jun",
|
|
42
|
+
7: "jul",
|
|
43
|
+
8: "aug",
|
|
44
|
+
9: "sep",
|
|
45
|
+
10: "oct",
|
|
46
|
+
11: "nov",
|
|
47
|
+
12: "dec",
|
|
48
|
+
}
|
|
49
|
+
MONTHS_AP = { # month names in Associated Press style
|
|
50
|
+
1: "Jan.",
|
|
51
|
+
2: "Feb.",
|
|
52
|
+
3: "March",
|
|
53
|
+
4: "April",
|
|
54
|
+
5: "May",
|
|
55
|
+
6: "June",
|
|
56
|
+
7: "July",
|
|
57
|
+
8: "Aug.",
|
|
58
|
+
9: "Sept.",
|
|
59
|
+
10: "Oct.",
|
|
60
|
+
11: "Nov.",
|
|
61
|
+
12: "Dec.",
|
|
62
|
+
}
|
|
63
|
+
MONTHS_ALT = { # required for long date representation by some locales
|
|
64
|
+
1: "January",
|
|
65
|
+
2: "February",
|
|
66
|
+
3: "March",
|
|
67
|
+
4: "April",
|
|
68
|
+
5: "May",
|
|
69
|
+
6: "June",
|
|
70
|
+
7: "July",
|
|
71
|
+
8: "August",
|
|
72
|
+
9: "September",
|
|
73
|
+
10: "October",
|
|
74
|
+
11: "November",
|
|
75
|
+
12: "December",
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def deconstructible(*args, path=None):
|
|
5
|
+
"""
|
|
6
|
+
Class decorator that allows the decorated class to be serialized
|
|
7
|
+
by the migrations subsystem.
|
|
8
|
+
|
|
9
|
+
The `path` kwarg specifies the import path.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def decorator(klass):
|
|
13
|
+
def __new__(cls, *args, **kwargs):
|
|
14
|
+
# We capture the arguments to make returning them trivial
|
|
15
|
+
obj = super(klass, cls).__new__(cls)
|
|
16
|
+
obj._constructor_args = (args, kwargs)
|
|
17
|
+
return obj
|
|
18
|
+
|
|
19
|
+
def deconstruct(obj):
|
|
20
|
+
"""
|
|
21
|
+
Return a 3-tuple of class import path, positional arguments,
|
|
22
|
+
and keyword arguments.
|
|
23
|
+
"""
|
|
24
|
+
# Fallback version
|
|
25
|
+
if path and type(obj) is klass:
|
|
26
|
+
module_name, _, name = path.rpartition(".")
|
|
27
|
+
else:
|
|
28
|
+
module_name = obj.__module__
|
|
29
|
+
name = obj.__class__.__name__
|
|
30
|
+
# Make sure it's actually there and not an inner class
|
|
31
|
+
module = import_module(module_name)
|
|
32
|
+
if not hasattr(module, name):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Could not find object {name} in {module_name}.\n"
|
|
35
|
+
"Please note that you cannot serialize things like inner "
|
|
36
|
+
"classes. Please move the object into the main module "
|
|
37
|
+
"body to use migrations."
|
|
38
|
+
)
|
|
39
|
+
return (
|
|
40
|
+
path
|
|
41
|
+
if path and type(obj) is klass
|
|
42
|
+
else f"{obj.__class__.__module__}.{name}",
|
|
43
|
+
obj._constructor_args[0],
|
|
44
|
+
obj._constructor_args[1],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
klass.__new__ = staticmethod(__new__)
|
|
48
|
+
klass.deconstruct = deconstruct
|
|
49
|
+
|
|
50
|
+
return klass
|
|
51
|
+
|
|
52
|
+
if not args:
|
|
53
|
+
return decorator
|
|
54
|
+
return decorator(*args)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"Functions that help with dynamically creating decorators for views."
|
|
2
|
+
|
|
3
|
+
from functools import partial, update_wrapper, wraps
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class classonlymethod(classmethod):
|
|
7
|
+
def __get__(self, instance, cls=None):
|
|
8
|
+
if instance is not None:
|
|
9
|
+
raise AttributeError(
|
|
10
|
+
"This method is available only on the class, not on instances."
|
|
11
|
+
)
|
|
12
|
+
return super().__get__(instance, cls)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _update_method_wrapper(_wrapper, decorator):
|
|
16
|
+
# _multi_decorate()'s bound_method isn't available in this scope. Cheat by
|
|
17
|
+
# using it on a dummy function.
|
|
18
|
+
@decorator
|
|
19
|
+
def dummy(*args, **kwargs):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
update_wrapper(_wrapper, dummy)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _multi_decorate(decorators, method):
|
|
26
|
+
"""
|
|
27
|
+
Decorate `method` with one or more function decorators. `decorators` can be
|
|
28
|
+
a single decorator or an iterable of decorators.
|
|
29
|
+
"""
|
|
30
|
+
if hasattr(decorators, "__iter__"):
|
|
31
|
+
# Apply a list/tuple of decorators if 'decorators' is one. Decorator
|
|
32
|
+
# functions are applied so that the call order is the same as the
|
|
33
|
+
# order in which they appear in the iterable.
|
|
34
|
+
decorators = decorators[::-1]
|
|
35
|
+
else:
|
|
36
|
+
decorators = [decorators]
|
|
37
|
+
|
|
38
|
+
def _wrapper(self, *args, **kwargs):
|
|
39
|
+
# bound_method has the signature that 'decorator' expects i.e. no
|
|
40
|
+
# 'self' argument, but it's a closure over self so it can call
|
|
41
|
+
# 'func'. Also, wrap method.__get__() in a function because new
|
|
42
|
+
# attributes can't be set on bound method objects, only on functions.
|
|
43
|
+
bound_method = wraps(method)(partial(method.__get__(self, type(self))))
|
|
44
|
+
for dec in decorators:
|
|
45
|
+
bound_method = dec(bound_method)
|
|
46
|
+
return bound_method(*args, **kwargs)
|
|
47
|
+
|
|
48
|
+
# Copy any attributes that a decorator adds to the function it decorates.
|
|
49
|
+
for dec in decorators:
|
|
50
|
+
_update_method_wrapper(_wrapper, dec)
|
|
51
|
+
# Preserve any existing attributes of 'method', including the name.
|
|
52
|
+
update_wrapper(_wrapper, method)
|
|
53
|
+
return _wrapper
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def method_decorator(decorator, name=""):
|
|
57
|
+
"""
|
|
58
|
+
Convert a function decorator into a method decorator
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# 'obj' can be a class or a function. If 'obj' is a function at the time it
|
|
62
|
+
# is passed to _dec, it will eventually be a method of the class it is
|
|
63
|
+
# defined on. If 'obj' is a class, the 'name' is required to be the name
|
|
64
|
+
# of the method that will be decorated.
|
|
65
|
+
def _dec(obj):
|
|
66
|
+
if not isinstance(obj, type):
|
|
67
|
+
return _multi_decorate(decorator, obj)
|
|
68
|
+
if not (name and hasattr(obj, name)):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"The keyword argument `name` must be the name of a method "
|
|
71
|
+
f"of the decorated class: {obj}. Got '{name}' instead."
|
|
72
|
+
)
|
|
73
|
+
method = getattr(obj, name)
|
|
74
|
+
if not callable(method):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"Cannot decorate '{name}' as it isn't a callable attribute of "
|
|
77
|
+
f"{obj} ({method})."
|
|
78
|
+
)
|
|
79
|
+
_wrapper = _multi_decorate(decorator, method)
|
|
80
|
+
setattr(obj, name, _wrapper)
|
|
81
|
+
return obj
|
|
82
|
+
|
|
83
|
+
# Don't worry about making _dec look similar to a list/tuple as it's rather
|
|
84
|
+
# meaningless.
|
|
85
|
+
if not hasattr(decorator, "__iter__"):
|
|
86
|
+
update_wrapper(_dec, decorator)
|
|
87
|
+
# Change the name to aid debugging.
|
|
88
|
+
obj = decorator if hasattr(decorator, "__name__") else decorator.__class__
|
|
89
|
+
_dec.__name__ = "method_decorator(%s)" % obj.__name__
|
|
90
|
+
return _dec
|
plain/utils/duration.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _get_duration_components(duration):
|
|
5
|
+
days = duration.days
|
|
6
|
+
seconds = duration.seconds
|
|
7
|
+
microseconds = duration.microseconds
|
|
8
|
+
|
|
9
|
+
minutes = seconds // 60
|
|
10
|
+
seconds %= 60
|
|
11
|
+
|
|
12
|
+
hours = minutes // 60
|
|
13
|
+
minutes %= 60
|
|
14
|
+
|
|
15
|
+
return days, hours, minutes, seconds, microseconds
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def duration_string(duration):
|
|
19
|
+
"""Version of str(timedelta) which is not English specific."""
|
|
20
|
+
days, hours, minutes, seconds, microseconds = _get_duration_components(duration)
|
|
21
|
+
|
|
22
|
+
string = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
23
|
+
if days:
|
|
24
|
+
string = f"{days} " + string
|
|
25
|
+
if microseconds:
|
|
26
|
+
string += f".{microseconds:06d}"
|
|
27
|
+
|
|
28
|
+
return string
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def duration_iso_string(duration):
|
|
32
|
+
if duration < datetime.timedelta(0):
|
|
33
|
+
sign = "-"
|
|
34
|
+
duration *= -1
|
|
35
|
+
else:
|
|
36
|
+
sign = ""
|
|
37
|
+
|
|
38
|
+
days, hours, minutes, seconds, microseconds = _get_duration_components(duration)
|
|
39
|
+
ms = f".{microseconds:06d}" if microseconds else ""
|
|
40
|
+
return f"{sign}P{days}DT{hours:02d}H{minutes:02d}M{seconds:02d}{ms}S"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def duration_microseconds(delta):
|
|
44
|
+
return (24 * 60 * 60 * delta.days + delta.seconds) * 1000000 + delta.microseconds
|
plain/utils/email.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
def normalize_email(email):
|
|
2
|
+
"""
|
|
3
|
+
Normalize the email address by lowercasing the domain part of it.
|
|
4
|
+
"""
|
|
5
|
+
email = email or ""
|
|
6
|
+
try:
|
|
7
|
+
email_name, domain_part = email.strip().rsplit("@", 1)
|
|
8
|
+
except ValueError:
|
|
9
|
+
pass
|
|
10
|
+
else:
|
|
11
|
+
email = email_name + "@" + domain_part.lower()
|
|
12
|
+
return email
|
plain/utils/encoding.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import codecs
|
|
2
|
+
import datetime
|
|
3
|
+
import locale
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from types import NoneType
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from plain.utils.functional import Promise
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlainUnicodeDecodeError(UnicodeDecodeError):
|
|
12
|
+
def __init__(self, obj, *args):
|
|
13
|
+
self.obj = obj
|
|
14
|
+
super().__init__(*args)
|
|
15
|
+
|
|
16
|
+
def __str__(self):
|
|
17
|
+
return f"{super().__str__()}. You passed in {self.obj!r} ({type(self.obj)})"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_PROTECTED_TYPES = (
|
|
21
|
+
NoneType,
|
|
22
|
+
int,
|
|
23
|
+
float,
|
|
24
|
+
Decimal,
|
|
25
|
+
datetime.datetime,
|
|
26
|
+
datetime.date,
|
|
27
|
+
datetime.time,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_protected_type(obj):
|
|
32
|
+
"""Determine if the object instance is of a protected type.
|
|
33
|
+
|
|
34
|
+
Objects of protected types are preserved as-is when passed to
|
|
35
|
+
force_str(strings_only=True).
|
|
36
|
+
"""
|
|
37
|
+
return isinstance(obj, _PROTECTED_TYPES)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def force_str(s, encoding="utf-8", strings_only=False, errors="strict"):
|
|
41
|
+
"""
|
|
42
|
+
Similar to smart_str(), except that lazy instances are resolved to
|
|
43
|
+
strings, rather than kept as lazy objects.
|
|
44
|
+
|
|
45
|
+
If strings_only is True, don't convert (some) non-string-like objects.
|
|
46
|
+
"""
|
|
47
|
+
# Handle the common case first for performance reasons.
|
|
48
|
+
if issubclass(type(s), str):
|
|
49
|
+
return s
|
|
50
|
+
if strings_only and is_protected_type(s):
|
|
51
|
+
return s
|
|
52
|
+
try:
|
|
53
|
+
if isinstance(s, bytes):
|
|
54
|
+
s = str(s, encoding, errors)
|
|
55
|
+
else:
|
|
56
|
+
s = str(s)
|
|
57
|
+
except UnicodeDecodeError as e:
|
|
58
|
+
raise PlainUnicodeDecodeError(s, *e.args)
|
|
59
|
+
return s
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def force_bytes(s, encoding="utf-8", strings_only=False, errors="strict"):
|
|
63
|
+
"""
|
|
64
|
+
Similar to smart_bytes, except that lazy instances are resolved to
|
|
65
|
+
strings, rather than kept as lazy objects.
|
|
66
|
+
|
|
67
|
+
If strings_only is True, don't convert (some) non-string-like objects.
|
|
68
|
+
"""
|
|
69
|
+
# Handle the common case first for performance reasons.
|
|
70
|
+
if isinstance(s, bytes):
|
|
71
|
+
if encoding == "utf-8":
|
|
72
|
+
return s
|
|
73
|
+
else:
|
|
74
|
+
return s.decode("utf-8", errors).encode(encoding, errors)
|
|
75
|
+
if strings_only and is_protected_type(s):
|
|
76
|
+
return s
|
|
77
|
+
if isinstance(s, memoryview):
|
|
78
|
+
return bytes(s)
|
|
79
|
+
return str(s).encode(encoding, errors)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def iri_to_uri(iri):
|
|
83
|
+
"""
|
|
84
|
+
Convert an Internationalized Resource Identifier (IRI) portion to a URI
|
|
85
|
+
portion that is suitable for inclusion in a URL.
|
|
86
|
+
|
|
87
|
+
This is the algorithm from RFC 3987 Section 3.1, slightly simplified since
|
|
88
|
+
the input is assumed to be a string rather than an arbitrary byte stream.
|
|
89
|
+
|
|
90
|
+
Take an IRI (string or UTF-8 bytes, e.g. '/I ♥ Plain/' or
|
|
91
|
+
b'/I \xe2\x99\xa5 Plain/') and return a string containing the encoded
|
|
92
|
+
result with ASCII chars only (e.g. '/I%20%E2%99%A5%20Plain/').
|
|
93
|
+
"""
|
|
94
|
+
# The list of safe characters here is constructed from the "reserved" and
|
|
95
|
+
# "unreserved" characters specified in RFC 3986 Sections 2.2 and 2.3:
|
|
96
|
+
# reserved = gen-delims / sub-delims
|
|
97
|
+
# gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
|
|
98
|
+
# sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
|
|
99
|
+
# / "*" / "+" / "," / ";" / "="
|
|
100
|
+
# unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
|
|
101
|
+
# Of the unreserved characters, urllib.parse.quote() already considers all
|
|
102
|
+
# but the ~ safe.
|
|
103
|
+
# The % character is also added to the list of safe characters here, as the
|
|
104
|
+
# end of RFC 3987 Section 3.1 specifically mentions that % must not be
|
|
105
|
+
# converted.
|
|
106
|
+
if iri is None:
|
|
107
|
+
return iri
|
|
108
|
+
elif isinstance(iri, Promise):
|
|
109
|
+
iri = str(iri)
|
|
110
|
+
return quote(iri, safe="/#%[]=:;$&()+,!?*@'~")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# List of byte values that uri_to_iri() decodes from percent encoding.
|
|
114
|
+
# First, the unreserved characters from RFC 3986:
|
|
115
|
+
_ascii_ranges = [[45, 46, 95, 126], range(65, 91), range(97, 123)]
|
|
116
|
+
_hextobyte = {
|
|
117
|
+
(fmt % char).encode(): bytes((char,))
|
|
118
|
+
for ascii_range in _ascii_ranges
|
|
119
|
+
for char in ascii_range
|
|
120
|
+
for fmt in ["%02x", "%02X"]
|
|
121
|
+
}
|
|
122
|
+
# And then everything above 128, because bytes ≥ 128 are part of multibyte
|
|
123
|
+
# Unicode characters.
|
|
124
|
+
_hexdig = "0123456789ABCDEFabcdef"
|
|
125
|
+
_hextobyte.update(
|
|
126
|
+
{(a + b).encode(): bytes.fromhex(a + b) for a in _hexdig[8:] for b in _hexdig}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def uri_to_iri(uri):
|
|
131
|
+
"""
|
|
132
|
+
Convert a Uniform Resource Identifier(URI) into an Internationalized
|
|
133
|
+
Resource Identifier(IRI).
|
|
134
|
+
|
|
135
|
+
This is the algorithm from RFC 3987 Section 3.2, excluding step 4.
|
|
136
|
+
|
|
137
|
+
Take an URI in ASCII bytes (e.g. '/I%20%E2%99%A5%20Plain/') and return
|
|
138
|
+
a string containing the encoded result (e.g. '/I%20♥%20Plain/').
|
|
139
|
+
"""
|
|
140
|
+
if uri is None:
|
|
141
|
+
return uri
|
|
142
|
+
uri = force_bytes(uri)
|
|
143
|
+
# Fast selective unquote: First, split on '%' and then starting with the
|
|
144
|
+
# second block, decode the first 2 bytes if they represent a hex code to
|
|
145
|
+
# decode. The rest of the block is the part after '%AB', not containing
|
|
146
|
+
# any '%'. Add that to the output without further processing.
|
|
147
|
+
bits = uri.split(b"%")
|
|
148
|
+
if len(bits) == 1:
|
|
149
|
+
iri = uri
|
|
150
|
+
else:
|
|
151
|
+
parts = [bits[0]]
|
|
152
|
+
append = parts.append
|
|
153
|
+
hextobyte = _hextobyte
|
|
154
|
+
for item in bits[1:]:
|
|
155
|
+
hex = item[:2]
|
|
156
|
+
if hex in hextobyte:
|
|
157
|
+
append(hextobyte[item[:2]])
|
|
158
|
+
append(item[2:])
|
|
159
|
+
else:
|
|
160
|
+
append(b"%")
|
|
161
|
+
append(item)
|
|
162
|
+
iri = b"".join(parts)
|
|
163
|
+
return repercent_broken_unicode(iri).decode()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def escape_uri_path(path):
|
|
167
|
+
"""
|
|
168
|
+
Escape the unsafe characters from the path portion of a Uniform Resource
|
|
169
|
+
Identifier (URI).
|
|
170
|
+
"""
|
|
171
|
+
# These are the "reserved" and "unreserved" characters specified in RFC
|
|
172
|
+
# 3986 Sections 2.2 and 2.3:
|
|
173
|
+
# reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
|
|
174
|
+
# unreserved = alphanum | mark
|
|
175
|
+
# mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
|
|
176
|
+
# The list of safe characters here is constructed subtracting ";", "=",
|
|
177
|
+
# and "?" according to RFC 3986 Section 3.3.
|
|
178
|
+
# The reason for not subtracting and escaping "/" is that we are escaping
|
|
179
|
+
# the entire path, not a path segment.
|
|
180
|
+
return quote(path, safe="/:@&+$,-_.!~*'()")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def punycode(domain):
|
|
184
|
+
"""Return the Punycode of the given domain if it's non-ASCII."""
|
|
185
|
+
return domain.encode("idna").decode("ascii")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def repercent_broken_unicode(path):
|
|
189
|
+
"""
|
|
190
|
+
As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
|
|
191
|
+
repercent-encode any octet produced that is not part of a strictly legal
|
|
192
|
+
UTF-8 octet sequence.
|
|
193
|
+
"""
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
path.decode()
|
|
197
|
+
except UnicodeDecodeError as e:
|
|
198
|
+
# CVE-2019-14235: A recursion shouldn't be used since the exception
|
|
199
|
+
# handling uses massive amounts of memory
|
|
200
|
+
repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
|
|
201
|
+
path = path[: e.start] + repercent.encode() + path[e.end :]
|
|
202
|
+
else:
|
|
203
|
+
return path
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def filepath_to_uri(path):
|
|
207
|
+
"""Convert a file system path to a URI portion that is suitable for
|
|
208
|
+
inclusion in a URL.
|
|
209
|
+
|
|
210
|
+
Encode certain chars that would normally be recognized as special chars
|
|
211
|
+
for URIs. Do not encode the ' character, as it is a valid character
|
|
212
|
+
within URIs. See the encodeURIComponent() JavaScript function for details.
|
|
213
|
+
"""
|
|
214
|
+
if path is None:
|
|
215
|
+
return path
|
|
216
|
+
# I know about `os.sep` and `os.altsep` but I want to leave
|
|
217
|
+
# some flexibility for hardcoding separators.
|
|
218
|
+
return quote(str(path).replace("\\", "/"), safe="/~!*()'")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_system_encoding():
|
|
222
|
+
"""
|
|
223
|
+
The encoding for the character type functions. Fallback to 'ascii' if the
|
|
224
|
+
#encoding is unsupported by Python or could not be determined. See tickets
|
|
225
|
+
#10335 and #5846.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
encoding = locale.getlocale()[1] or "ascii"
|
|
229
|
+
codecs.lookup(encoding)
|
|
230
|
+
except Exception:
|
|
231
|
+
encoding = "ascii"
|
|
232
|
+
return encoding
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
DEFAULT_LOCALE_ENCODING = get_system_encoding()
|