plain 0.69.0__py3-none-any.whl → 0.70.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.
Files changed (126) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +11 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +19 -8
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/cookie.py +15 -7
  41. plain/http/multipartparser.py +50 -30
  42. plain/http/request.py +97 -73
  43. plain/http/response.py +99 -80
  44. plain/internal/__init__.py +8 -1
  45. plain/internal/files/base.py +34 -18
  46. plain/internal/files/locks.py +19 -11
  47. plain/internal/files/move.py +8 -3
  48. plain/internal/files/temp.py +23 -5
  49. plain/internal/files/uploadedfile.py +42 -26
  50. plain/internal/files/uploadhandler.py +48 -27
  51. plain/internal/files/utils.py +13 -6
  52. plain/internal/handlers/base.py +20 -6
  53. plain/internal/handlers/exception.py +19 -5
  54. plain/internal/handlers/wsgi.py +30 -18
  55. plain/internal/middleware/headers.py +11 -2
  56. plain/internal/middleware/hosts.py +10 -2
  57. plain/internal/middleware/https.py +13 -3
  58. plain/internal/middleware/slash.py +15 -5
  59. plain/json.py +2 -1
  60. plain/logs/configure.py +3 -1
  61. plain/logs/debug.py +16 -5
  62. plain/logs/formatters.py +6 -3
  63. plain/logs/loggers.py +56 -52
  64. plain/logs/utils.py +19 -9
  65. plain/packages/config.py +14 -6
  66. plain/packages/registry.py +27 -12
  67. plain/paginator.py +31 -21
  68. plain/preflight/checks.py +3 -1
  69. plain/preflight/files.py +3 -1
  70. plain/preflight/registry.py +25 -10
  71. plain/preflight/results.py +10 -4
  72. plain/preflight/security.py +7 -5
  73. plain/preflight/urls.py +4 -1
  74. plain/runtime/__init__.py +4 -3
  75. plain/runtime/global_settings.py +1 -1
  76. plain/runtime/user_settings.py +26 -17
  77. plain/runtime/utils.py +1 -1
  78. plain/signals/dispatch/dispatcher.py +39 -17
  79. plain/signing.py +49 -30
  80. plain/templates/jinja/__init__.py +13 -5
  81. plain/templates/jinja/environments.py +4 -3
  82. plain/templates/jinja/extensions.py +9 -3
  83. plain/templates/jinja/filters.py +7 -2
  84. plain/templates/jinja/globals.py +1 -1
  85. plain/test/client.py +246 -174
  86. plain/test/encoding.py +9 -6
  87. plain/test/exceptions.py +10 -2
  88. plain/urls/converters.py +13 -10
  89. plain/urls/patterns.py +32 -20
  90. plain/urls/resolvers.py +32 -22
  91. plain/urls/utils.py +5 -1
  92. plain/utils/cache.py +14 -8
  93. plain/utils/crypto.py +21 -5
  94. plain/utils/datastructures.py +84 -54
  95. plain/utils/dateparse.py +10 -7
  96. plain/utils/deconstruct.py +12 -4
  97. plain/utils/decorators.py +5 -1
  98. plain/utils/duration.py +8 -4
  99. plain/utils/encoding.py +14 -7
  100. plain/utils/functional.py +62 -47
  101. plain/utils/hashable.py +5 -1
  102. plain/utils/html.py +21 -14
  103. plain/utils/http.py +16 -9
  104. plain/utils/inspect.py +14 -6
  105. plain/utils/ipv6.py +7 -3
  106. plain/utils/itercompat.py +6 -1
  107. plain/utils/module_loading.py +7 -3
  108. plain/utils/regex_helper.py +23 -13
  109. plain/utils/safestring.py +14 -6
  110. plain/utils/text.py +34 -18
  111. plain/utils/timezone.py +30 -19
  112. plain/utils/tree.py +31 -18
  113. plain/validators.py +71 -44
  114. plain/views/base.py +16 -6
  115. plain/views/errors.py +11 -4
  116. plain/views/exceptions.py +4 -1
  117. plain/views/objects.py +15 -15
  118. plain/views/redirect.py +14 -10
  119. plain/views/templates.py +1 -1
  120. plain/wsgi.py +3 -1
  121. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
  122. plain-0.70.0.dist-info/RECORD +169 -0
  123. plain-0.69.0.dist-info/RECORD +0 -169
  124. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
  125. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
  126. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  import copy
2
- from collections.abc import Mapping
4
+ from collections.abc import Callable, Iterable, Iterator, Mapping
5
+ from typing import Any, TypeVar
6
+
7
+ _KT = TypeVar("_KT")
8
+ _VT = TypeVar("_VT")
3
9
 
4
10
 
5
11
  class OrderedSet:
@@ -7,37 +13,37 @@ class OrderedSet:
7
13
  A set which keeps the ordering of the inserted items.
8
14
  """
9
15
 
10
- def __init__(self, iterable=None):
11
- self.dict = dict.fromkeys(iterable or ())
16
+ def __init__(self, iterable: Iterable[Any] | None = None) -> None:
17
+ self.dict: dict[Any, None] = dict.fromkeys(iterable or ())
12
18
 
13
- def add(self, item):
19
+ def add(self, item: Any) -> None:
14
20
  self.dict[item] = None
15
21
 
16
- def remove(self, item):
22
+ def remove(self, item: Any) -> None:
17
23
  del self.dict[item]
18
24
 
19
- def discard(self, item):
25
+ def discard(self, item: Any) -> None:
20
26
  try:
21
27
  self.remove(item)
22
28
  except KeyError:
23
29
  pass
24
30
 
25
- def __iter__(self):
31
+ def __iter__(self) -> Iterator[Any]:
26
32
  return iter(self.dict)
27
33
 
28
- def __reversed__(self):
34
+ def __reversed__(self) -> Iterator[Any]:
29
35
  return reversed(self.dict)
30
36
 
31
- def __contains__(self, item):
37
+ def __contains__(self, item: Any) -> bool:
32
38
  return item in self.dict
33
39
 
34
- def __bool__(self):
40
+ def __bool__(self) -> bool:
35
41
  return bool(self.dict)
36
42
 
37
- def __len__(self):
43
+ def __len__(self) -> int:
38
44
  return len(self.dict)
39
45
 
40
- def __repr__(self):
46
+ def __repr__(self) -> str:
41
47
  data = repr(list(self.dict)) if self.dict else ""
42
48
  return f"{self.__class__.__qualname__}({data})"
43
49
 
@@ -46,7 +52,7 @@ class MultiValueDictKeyError(KeyError):
46
52
  pass
47
53
 
48
54
 
49
- class MultiValueDict(dict):
55
+ class MultiValueDict(dict[str, list[Any]]):
50
56
  """
51
57
  A subclass of dictionary customized to handle multiple values for the
52
58
  same key.
@@ -69,13 +75,17 @@ class MultiValueDict(dict):
69
75
  single name-value pairs.
70
76
  """
71
77
 
72
- def __init__(self, key_to_list_mapping=()):
78
+ def __init__(
79
+ self,
80
+ key_to_list_mapping: Mapping[str, list[Any]]
81
+ | Iterable[tuple[str, list[Any]]] = (),
82
+ ) -> None:
73
83
  super().__init__(key_to_list_mapping)
74
84
 
75
- def __repr__(self):
85
+ def __repr__(self) -> str:
76
86
  return f"<{self.__class__.__name__}: {super().__repr__()}>"
77
87
 
78
- def __getitem__(self, key):
88
+ def __getitem__(self, key: str) -> Any:
79
89
  """
80
90
  Return the last data value for this key, or [] if it's an empty list;
81
91
  raise KeyError if not found.
@@ -89,13 +99,13 @@ class MultiValueDict(dict):
89
99
  except IndexError:
90
100
  return []
91
101
 
92
- def __setitem__(self, key, value):
102
+ def __setitem__(self, key: str, value: Any) -> None:
93
103
  super().__setitem__(key, [value])
94
104
 
95
- def __copy__(self):
105
+ def __copy__(self) -> MultiValueDict:
96
106
  return self.__class__([(k, v[:]) for k, v in self.lists()])
97
107
 
98
- def __deepcopy__(self, memo):
108
+ def __deepcopy__(self, memo: dict[int, Any]) -> MultiValueDict:
99
109
  result = self.__class__()
100
110
  memo[id(self)] = result
101
111
  for key, value in dict.items(self):
@@ -104,16 +114,16 @@ class MultiValueDict(dict):
104
114
  )
105
115
  return result
106
116
 
107
- def __getstate__(self):
117
+ def __getstate__(self) -> dict[str, Any]:
108
118
  return {**self.__dict__, "_data": {k: self._getlist(k) for k in self}}
109
119
 
110
- def __setstate__(self, obj_dict):
120
+ def __setstate__(self, obj_dict: dict[str, Any]) -> None:
111
121
  data = obj_dict.pop("_data", {})
112
122
  for k, v in data.items():
113
123
  self.setlist(k, v)
114
124
  self.__dict__.update(obj_dict)
115
125
 
116
- def get(self, key, default=None):
126
+ def get(self, key: str, default: Any = None) -> Any:
117
127
  """
118
128
  Return the last data value for the passed key. If key doesn't exist
119
129
  or value is an empty list, return `default`.
@@ -126,7 +136,9 @@ class MultiValueDict(dict):
126
136
  return default
127
137
  return val
128
138
 
129
- def _getlist(self, key, default=None, force_list=False):
139
+ def _getlist(
140
+ self, key: str, default: list[Any] | None = None, force_list: bool = False
141
+ ) -> list[Any] | None:
130
142
  """
131
143
  Return a list of values for the key.
132
144
 
@@ -144,24 +156,26 @@ class MultiValueDict(dict):
144
156
  values = list(values) if values is not None else None
145
157
  return values
146
158
 
147
- def getlist(self, key, default=None):
159
+ def getlist(self, key: str, default: list[Any] | None = None) -> list[Any]:
148
160
  """
149
161
  Return the list of values for the key. If key doesn't exist, return a
150
162
  default value.
151
163
  """
152
164
  return self._getlist(key, default, force_list=True)
153
165
 
154
- def setlist(self, key, list_):
166
+ def setlist(self, key: str, list_: list[Any]) -> None:
155
167
  super().__setitem__(key, list_)
156
168
 
157
- def setdefault(self, key, default=None):
169
+ def setdefault(self, key: str, default: Any = None) -> Any:
158
170
  if key not in self:
159
171
  self[key] = default
160
172
  # Do not return default here because __setitem__() may store
161
173
  # another value -- QueryDict.__setitem__() does. Look it up.
162
174
  return self[key]
163
175
 
164
- def setlistdefault(self, key, default_list=None):
176
+ def setlistdefault(
177
+ self, key: str, default_list: list[Any] | None = None
178
+ ) -> list[Any]:
165
179
  if key not in self:
166
180
  if default_list is None:
167
181
  default_list = []
@@ -170,11 +184,11 @@ class MultiValueDict(dict):
170
184
  # another value -- QueryDict.setlist() does. Look it up.
171
185
  return self._getlist(key)
172
186
 
173
- def appendlist(self, key, value):
187
+ def appendlist(self, key: str, value: Any) -> None:
174
188
  """Append an item to the internal list associated with key."""
175
189
  self.setlistdefault(key).append(value)
176
190
 
177
- def items(self):
191
+ def items(self) -> Iterator[tuple[str, Any]]:
178
192
  """
179
193
  Yield (key, value) pairs, where value is the last item in the list
180
194
  associated with the key.
@@ -182,23 +196,23 @@ class MultiValueDict(dict):
182
196
  for key in self:
183
197
  yield key, self[key]
184
198
 
185
- def lists(self):
199
+ def lists(self) -> Iterator[tuple[str, list[Any]]]:
186
200
  """Yield (key, list) pairs."""
187
201
  return iter(super().items())
188
202
 
189
- def values(self):
203
+ def values(self) -> Iterator[Any]:
190
204
  """Yield the last value on every key list."""
191
205
  for key in self:
192
206
  yield self[key]
193
207
 
194
- def copy(self):
208
+ def copy(self) -> MultiValueDict:
195
209
  """Return a shallow copy of this object."""
196
210
  return copy.copy(self)
197
211
 
198
- def update(self, *args, **kwargs):
212
+ def update(self, *args: Any, **kwargs: Any) -> None:
199
213
  """Extend rather than replace existing key lists."""
200
214
  if len(args) > 1:
201
- raise TypeError("update expected at most 1 argument, got %d" % len(args)) # noqa: UP031
215
+ raise TypeError(f"update expected at most 1 argument, got {len(args)}")
202
216
  if args:
203
217
  arg = args[0]
204
218
  if isinstance(arg, MultiValueDict):
@@ -212,7 +226,7 @@ class MultiValueDict(dict):
212
226
  for key, value in kwargs.items():
213
227
  self.setlistdefault(key).append(value)
214
228
 
215
- def dict(self):
229
+ def dict(self) -> dict[str, Any]:
216
230
  """Return current object as a dict with singular values."""
217
231
  return {key: self[key] for key in self}
218
232
 
@@ -230,12 +244,17 @@ class ImmutableList(tuple):
230
244
  AttributeError: You cannot mutate this.
231
245
  """
232
246
 
233
- def __new__(cls, *args, warning="ImmutableList object is immutable.", **kwargs):
247
+ def __new__(
248
+ cls,
249
+ *args: Any,
250
+ warning: str = "ImmutableList object is immutable.",
251
+ **kwargs: Any,
252
+ ) -> ImmutableList:
234
253
  self = tuple.__new__(cls, *args, **kwargs)
235
254
  self.warning = warning
236
255
  return self
237
256
 
238
- def complain(self, *args, **kwargs):
257
+ def complain(self, *args: Any, **kwargs: Any) -> None:
239
258
  raise AttributeError(self.warning)
240
259
 
241
260
  # All list mutation functions complain.
@@ -254,7 +273,7 @@ class ImmutableList(tuple):
254
273
  reverse = complain
255
274
 
256
275
 
257
- class DictWrapper(dict):
276
+ class DictWrapper(dict[str, Any]):
258
277
  """
259
278
  Wrap accesses to a dictionary so that certain values (those starting with
260
279
  the specified prefix) are passed through a function before being returned.
@@ -264,12 +283,14 @@ class DictWrapper(dict):
264
283
  quoted before being used.
265
284
  """
266
285
 
267
- def __init__(self, data, func, prefix):
286
+ def __init__(
287
+ self, data: dict[str, Any], func: Callable[[Any], Any], prefix: str
288
+ ) -> None:
268
289
  super().__init__(data)
269
290
  self.func = func
270
291
  self.prefix = prefix
271
292
 
272
- def __getitem__(self, key):
293
+ def __getitem__(self, key: str) -> Any:
273
294
  """
274
295
  Retrieve the real value after stripping the prefix string (if
275
296
  present). If the prefix is present, pass the value through self.func
@@ -283,7 +304,7 @@ class DictWrapper(dict):
283
304
  return value
284
305
 
285
306
 
286
- class CaseInsensitiveMapping(Mapping):
307
+ class CaseInsensitiveMapping(Mapping[str, Any]):
287
308
  """
288
309
  Mapping allowing case-insensitive key lookups. Original case of keys is
289
310
  preserved for iteration and string representation.
@@ -301,35 +322,44 @@ class CaseInsensitiveMapping(Mapping):
301
322
  {'name': 'Jane'}
302
323
  """
303
324
 
304
- def __init__(self, data):
305
- self._store = {k.lower(): (k, v) for k, v in self._unpack_items(data)}
325
+ def __init__(self, data: Mapping[str, Any] | Iterable[tuple[str, Any]]) -> None:
326
+ self._store: dict[str, tuple[str, Any]] = {
327
+ k.lower(): (k, v) for k, v in self._unpack_items(data)
328
+ }
306
329
 
307
- def __getitem__(self, key):
330
+ def __getitem__(self, key: str) -> Any:
308
331
  return self._store[key.lower()][1]
309
332
 
310
- def __len__(self):
333
+ def __len__(self) -> int:
311
334
  return len(self._store)
312
335
 
313
- def __eq__(self, other):
314
- return isinstance(other, Mapping) and {
315
- k.lower(): v for k, v in self.items()
316
- } == {k.lower(): v for k, v in other.items()}
336
+ def __eq__(self, other: object) -> bool:
337
+ if not isinstance(other, Mapping):
338
+ return False
339
+ return {k.lower(): v for k, v in self.items()} == {
340
+ k.lower(): v for k, v in other.items() if isinstance(k, str)
341
+ }
317
342
 
318
- def __iter__(self):
343
+ def __iter__(self) -> Iterator[str]:
319
344
  return (original_key for original_key, value in self._store.values())
320
345
 
321
- def __repr__(self):
346
+ def __repr__(self) -> str:
322
347
  return repr(dict(self._store.values()))
323
348
 
324
- def copy(self):
349
+ def copy(self) -> CaseInsensitiveMapping:
325
350
  return self
326
351
 
327
352
  @staticmethod
328
- def _unpack_items(data):
353
+ def _unpack_items(
354
+ data: Mapping[str, Any] | Iterable[tuple[str, Any]],
355
+ ) -> Iterator[tuple[str, Any]]:
329
356
  # Explicitly test for dict first as the common case for performance,
330
357
  # avoiding abc's __instancecheck__ and _abc_instancecheck for the
331
358
  # general Mapping case.
332
- if isinstance(data, dict | Mapping):
359
+ if isinstance(data, dict):
360
+ yield from data.items()
361
+ return
362
+ if isinstance(data, Mapping):
333
363
  yield from data.items()
334
364
  return
335
365
  for i, elem in enumerate(data):
plain/utils/dateparse.py CHANGED
@@ -1,10 +1,11 @@
1
1
  """Functions to parse datetime objects."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  # We're using regular expressions rather than time.strptime because:
4
6
  # - They provide both validation and parsing.
5
7
  # - They're more flexible for datetimes.
6
8
  # - The date/datetime/time constructors produce friendlier error messages.
7
-
8
9
  import datetime
9
10
 
10
11
  from plain.utils.regex_helper import _lazy_re_compile
@@ -64,7 +65,7 @@ postgres_interval_re = _lazy_re_compile(
64
65
  )
65
66
 
66
67
 
67
- def parse_date(value):
68
+ def parse_date(value: str) -> datetime.date | None:
68
69
  """Parse a string and return a datetime.date.
69
70
 
70
71
  Raise ValueError if the input is well formatted but not a valid date.
@@ -75,10 +76,11 @@ def parse_date(value):
75
76
  except ValueError:
76
77
  if match := date_re.match(value):
77
78
  kw = {k: int(v) for k, v in match.groupdict().items()}
78
- return datetime.date(**kw)
79
+ return datetime.date(**kw) # type: ignore[arg-type]
80
+ return None
79
81
 
80
82
 
81
- def parse_time(value):
83
+ def parse_time(value: str) -> datetime.time | None:
82
84
  """Parse a string and return a datetime.time.
83
85
 
84
86
  This function doesn't support time zone offsets.
@@ -101,7 +103,7 @@ def parse_time(value):
101
103
  return datetime.time(**kw)
102
104
 
103
105
 
104
- def parse_datetime(value):
106
+ def parse_datetime(value: str) -> datetime.datetime | None:
105
107
  """Parse a string and return a datetime.datetime.
106
108
 
107
109
  This function supports time zone offsets. When the input contains one,
@@ -126,10 +128,11 @@ def parse_datetime(value):
126
128
  offset = -offset
127
129
  tzinfo = get_fixed_timezone(offset)
128
130
  kw = {k: int(v) for k, v in kw.items() if v is not None}
129
- return datetime.datetime(**kw, tzinfo=tzinfo)
131
+ return datetime.datetime(**kw, tzinfo=tzinfo) # type: ignore[arg-type]
132
+ return None
130
133
 
131
134
 
132
- def parse_duration(value):
135
+ def parse_duration(value: str) -> datetime.timedelta | None:
133
136
  """Parse a duration string and return a datetime.timedelta.
134
137
 
135
138
  The preferred format for durations in Plain is '%d %H:%M:%S.%f'.
@@ -1,7 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
1
4
  from importlib import import_module
5
+ from typing import Any, TypeVar
6
+
7
+ T = TypeVar("T")
2
8
 
3
9
 
4
- def deconstructible(*args, path=None):
10
+ def deconstructible(
11
+ *args: type[T], path: str | None = None
12
+ ) -> Callable[[type[T]], type[T]] | type[T]:
5
13
  """
6
14
  Class decorator that allows the decorated class to be serialized
7
15
  by the migrations subsystem.
@@ -9,14 +17,14 @@ def deconstructible(*args, path=None):
9
17
  The `path` kwarg specifies the import path.
10
18
  """
11
19
 
12
- def decorator(klass):
13
- def __new__(cls, *args, **kwargs):
20
+ def decorator(klass: type[T]) -> type[T]:
21
+ def __new__(cls: type[T], *args: Any, **kwargs: Any) -> T:
14
22
  # We capture the arguments to make returning them trivial
15
23
  obj = super(klass, cls).__new__(cls)
16
24
  obj._constructor_args = (args, kwargs)
17
25
  return obj
18
26
 
19
- def deconstruct(obj):
27
+ def deconstruct(obj: Any) -> tuple[str, tuple[Any, ...], dict[str, Any]]:
20
28
  """
21
29
  Return a 3-tuple of class import path, positional arguments,
22
30
  and keyword arguments.
plain/utils/decorators.py CHANGED
@@ -1,8 +1,12 @@
1
1
  "Functions that help with dynamically creating decorators for views."
2
2
 
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
3
7
 
4
8
  class classonlymethod(classmethod):
5
- def __get__(self, instance, cls=None):
9
+ def __get__(self, instance: object | None, cls: type | None = None) -> Any:
6
10
  if instance is not None:
7
11
  raise AttributeError(
8
12
  "This method is available only on the class, not on instances."
plain/utils/duration.py CHANGED
@@ -1,7 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
 
3
5
 
4
- def _get_duration_components(duration):
6
+ def _get_duration_components(
7
+ duration: datetime.timedelta,
8
+ ) -> tuple[int, int, int, int, int]:
5
9
  days = duration.days
6
10
  seconds = duration.seconds
7
11
  microseconds = duration.microseconds
@@ -15,7 +19,7 @@ def _get_duration_components(duration):
15
19
  return days, hours, minutes, seconds, microseconds
16
20
 
17
21
 
18
- def duration_string(duration):
22
+ def duration_string(duration: datetime.timedelta) -> str:
19
23
  """Version of str(timedelta) which is not English specific."""
20
24
  days, hours, minutes, seconds, microseconds = _get_duration_components(duration)
21
25
 
@@ -28,7 +32,7 @@ def duration_string(duration):
28
32
  return string
29
33
 
30
34
 
31
- def duration_iso_string(duration):
35
+ def duration_iso_string(duration: datetime.timedelta) -> str:
32
36
  if duration < datetime.timedelta(0):
33
37
  sign = "-"
34
38
  duration *= -1
@@ -40,5 +44,5 @@ def duration_iso_string(duration):
40
44
  return f"{sign}P{days}DT{hours:02d}H{minutes:02d}M{seconds:02d}{ms}S"
41
45
 
42
46
 
43
- def duration_microseconds(delta):
47
+ def duration_microseconds(delta: datetime.timedelta) -> int:
44
48
  return (24 * 60 * 60 * delta.days + delta.seconds) * 1000000 + delta.microseconds
plain/utils/encoding.py CHANGED
@@ -1,17 +1,20 @@
1
+ from __future__ import annotations
2
+
1
3
  import datetime
2
4
  from decimal import Decimal
3
5
  from types import NoneType
6
+ from typing import Any
4
7
  from urllib.parse import quote
5
8
 
6
9
  from plain.utils.functional import Promise
7
10
 
8
11
 
9
12
  class PlainUnicodeDecodeError(UnicodeDecodeError):
10
- def __init__(self, obj, *args):
13
+ def __init__(self, obj: Any, *args: Any):
11
14
  self.obj = obj
12
15
  super().__init__(*args)
13
16
 
14
- def __str__(self):
17
+ def __str__(self) -> str:
15
18
  return f"{super().__str__()}. You passed in {self.obj!r} ({type(self.obj)})"
16
19
 
17
20
 
@@ -26,7 +29,7 @@ _PROTECTED_TYPES = (
26
29
  )
27
30
 
28
31
 
29
- def is_protected_type(obj):
32
+ def is_protected_type(obj: Any) -> bool:
30
33
  """Determine if the object instance is of a protected type.
31
34
 
32
35
  Objects of protected types are preserved as-is when passed to
@@ -35,7 +38,9 @@ def is_protected_type(obj):
35
38
  return isinstance(obj, _PROTECTED_TYPES)
36
39
 
37
40
 
38
- def force_str(s, encoding="utf-8", strings_only=False, errors="strict"):
41
+ def force_str(
42
+ s: Any, encoding: str = "utf-8", strings_only: bool = False, errors: str = "strict"
43
+ ) -> str | Any:
39
44
  """
40
45
  Similar to smart_str(), except that lazy instances are resolved to
41
46
  strings, rather than kept as lazy objects.
@@ -57,7 +62,9 @@ def force_str(s, encoding="utf-8", strings_only=False, errors="strict"):
57
62
  return s
58
63
 
59
64
 
60
- def force_bytes(s, encoding="utf-8", strings_only=False, errors="strict"):
65
+ def force_bytes(
66
+ s: Any, encoding: str = "utf-8", strings_only: bool = False, errors: str = "strict"
67
+ ) -> bytes | Any:
61
68
  """
62
69
  Similar to smart_bytes, except that lazy instances are resolved to
63
70
  strings, rather than kept as lazy objects.
@@ -77,7 +84,7 @@ def force_bytes(s, encoding="utf-8", strings_only=False, errors="strict"):
77
84
  return str(s).encode(encoding, errors)
78
85
 
79
86
 
80
- def iri_to_uri(iri):
87
+ def iri_to_uri(iri: str | Promise | None) -> str | None:
81
88
  """
82
89
  Convert an Internationalized Resource Identifier (IRI) portion to a URI
83
90
  portion that is suitable for inclusion in a URL.
@@ -125,6 +132,6 @@ _hextobyte.update(
125
132
  )
126
133
 
127
134
 
128
- def punycode(domain):
135
+ def punycode(domain: str) -> str:
129
136
  """Return the Punycode of the given domain if it's non-ASCII."""
130
137
  return domain.encode("idna").decode("ascii")