bareutils 4.0.2__tar.gz → 5.0.0a2__tar.gz

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 (31) hide show
  1. bareutils-5.0.0a2/PKG-INFO +51 -0
  2. {bareutils-4.0.2 → bareutils-5.0.0a2}/README.md +1 -1
  3. bareutils-5.0.0a2/pyproject.toml +96 -0
  4. bareutils-5.0.0a2/setup.cfg +4 -0
  5. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/__init__.py +2 -0
  6. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/compression.py +89 -11
  7. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/cookies.py +32 -37
  8. bareutils-5.0.0a2/src/bareutils/dates/constants.py +7 -0
  9. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/dates/helpers.py +1 -2
  10. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/dates/rfc_7231.py +4 -6
  11. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/dates/rfc_850.py +4 -6
  12. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/header.py +503 -354
  13. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/unpacking.py +6 -6
  14. bareutils-5.0.0a2/src/bareutils.egg-info/PKG-INFO +51 -0
  15. bareutils-5.0.0a2/src/bareutils.egg-info/SOURCES.txt +26 -0
  16. bareutils-5.0.0a2/src/bareutils.egg-info/dependency_links.txt +1 -0
  17. bareutils-5.0.0a2/src/bareutils.egg-info/requires.txt +15 -0
  18. bareutils-5.0.0a2/src/bareutils.egg-info/top_level.txt +1 -0
  19. bareutils-5.0.0a2/tests/test_compression.py +35 -0
  20. bareutils-5.0.0a2/tests/test_cookies.py +147 -0
  21. bareutils-5.0.0a2/tests/test_header.py +895 -0
  22. bareutils-5.0.0a2/tests/test_response_code.py +21 -0
  23. bareutils-5.0.0a2/tests/test_streaming.py +34 -0
  24. bareutils-4.0.2/PKG-INFO +0 -34
  25. bareutils-4.0.2/pyproject.toml +0 -30
  26. bareutils-4.0.2/setup.py +0 -26
  27. {bareutils-4.0.2 → bareutils-5.0.0a2}/LICENSE +0 -0
  28. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/dates/__init__.py +0 -0
  29. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/py.typed +0 -0
  30. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/response_code.py +0 -0
  31. {bareutils-4.0.2 → bareutils-5.0.0a2/src}/bareutils/streaming.py +0 -0
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: bareutils
3
+ Version: 5.0.0a2
4
+ Summary: Utilities for bareASGI and bareClient
5
+ Author-email: Rob Blackbourn <rob.blackbourn@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://rob-blackbourn.github.io/bareUtils
8
+ Project-URL: Repository, https://github.com/rob-blackbourn/bareUtils
9
+ Project-URL: Issues, https://github.com/rob-blackbourn/bareUtils/issues
10
+ Keywords: asgi,web,framework,asyncio,http
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: autopep8; extra == "dev"
24
+ Requires-Dist: coverage; extra == "dev"
25
+ Requires-Dist: mypy; extra == "dev"
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-asyncio; extra == "dev"
28
+ Requires-Dist: pylint; extra == "dev"
29
+ Requires-Dist: types-setuptools; extra == "dev"
30
+ Provides-Extra: docs
31
+ Requires-Dist: mkdocs; extra == "docs"
32
+ Requires-Dist: mkdocs-material; extra == "docs"
33
+ Requires-Dist: jetblack-markdown; extra == "docs"
34
+ Requires-Dist: mike; extra == "docs"
35
+ Dynamic: license-file
36
+
37
+ # bareutils
38
+
39
+ Utilities for [bareASGI](https://github.com/rob-blackbourn/bareASGI)
40
+ and [bareClient](https://github.com/rob-blackbourn/bareClient)
41
+ (read the [docs](https://rob-blackbourn.github.io/bareUtils/)).
42
+
43
+ ## Installation
44
+
45
+ The package can be installed with pip.
46
+
47
+ ```bash
48
+ pip install bareutils
49
+ ```
50
+
51
+ This is a Python 3.12 and later package.
@@ -12,4 +12,4 @@ The package can be installed with pip.
12
12
  pip install bareutils
13
13
  ```
14
14
 
15
- This is a Python3.7 and later package.
15
+ This is a Python 3.12 and later package.
@@ -0,0 +1,96 @@
1
+ [project]
2
+ name = "bareutils"
3
+ version = "5.0.0-alpha.2"
4
+ description = "Utilities for bareASGI and bareClient"
5
+ authors = [
6
+ { name = "Rob Blackbourn", email = "rob.blackbourn@gmail.com" }
7
+ ]
8
+ requires-python = ">=3.12"
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Environment :: Web Environment",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Topic :: Internet :: WWW/HTTP",
18
+ ]
19
+ readme = "README.md"
20
+ license = "Apache-2.0"
21
+ license-files = [
22
+ "LICENSE"
23
+ ]
24
+ keywords = [
25
+ "asgi",
26
+ "web",
27
+ "framework",
28
+ "asyncio",
29
+ "http",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "autopep8",
35
+ "coverage",
36
+ "mypy",
37
+ "pytest",
38
+ "pytest-asyncio",
39
+ "pylint",
40
+ "types-setuptools",
41
+ ]
42
+ docs = [
43
+ "mkdocs",
44
+ "mkdocs-material",
45
+ "jetblack-markdown",
46
+ "mike",
47
+ ]
48
+
49
+ [project.urls]
50
+ Homepage = "https://rob-blackbourn.github.io/bareUtils"
51
+ Repository = "https://github.com/rob-blackbourn/bareUtils"
52
+ Issues = "https://github.com/rob-blackbourn/bareUtils/issues"
53
+
54
+ [build-system]
55
+ requires = ["setuptools>=61.0"]
56
+ build-backend = "setuptools.build_meta"
57
+
58
+ # pylint
59
+ [tool.pylint.main]
60
+ py-version = "3.12"
61
+ jobs = 4
62
+
63
+ [tool.pylint.basic]
64
+ good-names = ["i", "j", "k", "ex", "Run", "_"]
65
+ bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"]
66
+
67
+ [tool.pylint."messages control"]
68
+ disable = [
69
+ "raw-checker-failed",
70
+ "bad-inline-option",
71
+ "locally-disabled",
72
+ "file-ignored",
73
+ "suppressed-message",
74
+ "useless-suppression",
75
+ "deprecated-pragma",
76
+ "use-symbolic-message-instead",
77
+ "use-implicit-booleaness-not-comparison-to-string",
78
+ "use-implicit-booleaness-not-comparison-to-zero",
79
+ "missing-module-docstring",
80
+ ]
81
+
82
+ # pytest
83
+ [tool.pytest.ini_options]
84
+ testpaths = [
85
+ "tests",
86
+ ]
87
+ asyncio_default_fixture_loop_scope = "session"
88
+ asyncio_default_test_loop_scope = "function"
89
+
90
+ # mypy
91
+ [tool.mypy]
92
+ files = [ "src/bareutils", "tests" ]
93
+
94
+ [[tool.mypy.overrides]]
95
+ module = "pytest"
96
+ ignore_missing_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -31,6 +31,8 @@ from .cookies import (
31
31
  decode_cookies
32
32
  )
33
33
 
34
+ __version__ = "5.0.0.alpha.1"
35
+
34
36
  __all__ = [
35
37
  "text_writer",
36
38
  "text_reader",
@@ -1,10 +1,13 @@
1
- """
2
- Compression streaming.
1
+ """Compression utilities
2
+
3
+ Only compression directly supported by standard library functions are provided
4
+ here to avoid the need for additional dependencies. Other compression methods
5
+ should be implemented in a separate module.
3
6
  """
4
7
 
5
8
  from __future__ import annotations
6
9
  from abc import ABCMeta, abstractmethod
7
- from typing import AsyncIterable, Callable, Optional, cast
10
+ from typing import AsyncIterable, Callable, cast
8
11
  import zlib
9
12
 
10
13
  from .streaming import bytes_writer, bytes_reader
@@ -24,6 +27,7 @@ class Compressor(metaclass=ABCMeta):
24
27
  bytes: The compressed buffer.
25
28
  """
26
29
 
30
+ @abstractmethod
27
31
  def flush(self) -> bytes:
28
32
  """Flush the compressor
29
33
 
@@ -116,29 +120,82 @@ class Decompressor(metaclass=ABCMeta):
116
120
  @property
117
121
  @abstractmethod
118
122
  def unused_data(self) -> bytes:
119
- ...
123
+ """A bytes object which contains any bytes past the end of the
124
+ compressed data.
125
+
126
+ That is, this remains b"" until the last byte that contains compression
127
+ data is available. If the whole bytestring turned out to contain
128
+ compressed data, this is b"", an empty bytes object.
129
+ """
120
130
 
121
131
  @property
122
132
  @abstractmethod
123
133
  def unconsumed_tail(self) -> bytes:
124
- ...
134
+ """A bytes object that contains any data that was not consumed by the
135
+ last decompress() call because it exceeded the limit for the
136
+ uncompressed data buffer.
137
+ """
125
138
 
126
139
  @property
127
140
  @abstractmethod
128
141
  def eof(self) -> bool:
129
- ...
142
+ """A boolean indicating whether the end of the compressed data stream has been reached.
143
+
144
+ This makes it possible to distinguish between a properly formed compressed
145
+ stream, and an incomplete or truncated one.
146
+ """
130
147
 
131
148
  @abstractmethod
132
149
  def decompress(self, buf: bytes, max_length: int = 0) -> bytes:
133
- ...
150
+ """Decompress data, returning a bytes object containing the uncompressed
151
+ data corresponding to at least part of the data in string.
152
+
153
+ This data should be concatenated to the output produced by any preceding
154
+ calls to the decompress() method. Some of the input data may be
155
+ preserved in internal buffers for later processing.
156
+
157
+ If the optional parameter max_length is non-zero then the return value
158
+ will be no longer than max_length. This may mean that not all of the
159
+ compressed input can be processed; and unconsumed data will be stored in
160
+ the attribute unconsumed_tail. This bytestring must be passed to a
161
+ subsequent call to decompress() if decompression is to continue. If
162
+ max_length is zero then the whole input is decompressed, and
163
+ unconsumed_tail is empty.
164
+
165
+ Args:
166
+ buf (bytes): The data to decompress.
167
+ max_length (int, optional): Max length of output. Defaults to 0.
168
+
169
+ Returns:
170
+ bytes: The decompressed data.
171
+ """
134
172
 
135
173
  @abstractmethod
136
- def flush(self, length: Optional[int] = None) -> bytes:
137
- ...
174
+ def flush(self, length: int | None = None) -> bytes:
175
+ """All pending input is processed, and a bytes object containing the
176
+ remaining uncompressed output is returned. After calling flush(), the
177
+ decompress() method cannot be called again; the only realistic action is
178
+ to delete the object.
179
+
180
+ Args:
181
+ length (int | None, optional): The initial size of the output
182
+ buffer. Defaults to None.
183
+
184
+ Returns:
185
+ bytes: The remaining uncompressed output.
186
+ """
138
187
 
139
188
  @abstractmethod
140
189
  def copy(self) -> Decompressor:
141
- ...
190
+ """Returns a copy of the decompression object.
191
+
192
+ This can be used to save the state of the decompressor midway through
193
+ the data stream in order to speed up random seeks into the stream at a
194
+ future point.
195
+
196
+ Returns:
197
+ Decompressor: A copy of the decompressor.
198
+ """
142
199
 
143
200
 
144
201
  DecompressorFactory = Callable[[], Decompressor]
@@ -178,7 +235,7 @@ def make_compress_decompressobj() -> Decompressor:
178
235
  """
179
236
  return cast(
180
237
  Decompressor,
181
- zlib.decompressobj(9, zlib.DEFLATED, zlib.MAX_WBITS) # type: ignore
238
+ zlib.decompressobj(zlib.MAX_WBITS)
182
239
  )
183
240
 
184
241
 
@@ -186,6 +243,18 @@ async def compression_reader_adapter(
186
243
  reader: AsyncIterable[bytes],
187
244
  decompressobj: Decompressor
188
245
  ) -> AsyncIterable[bytes]:
246
+ """Adapters a reader to decompress the data.
247
+
248
+ Args:
249
+ reader (AsyncIterable[bytes]): The reader.
250
+ decompressobj (Decompressor): The decompressor.
251
+
252
+ Returns:
253
+ AsyncIterable[bytes]: An async iterable of decompressed bytes.
254
+
255
+ Yields:
256
+ bytes: Decompressed bytes.
257
+ """
189
258
  async for item in reader:
190
259
  yield decompressobj.decompress(item)
191
260
  yield decompressobj.flush()
@@ -195,4 +264,13 @@ async def compression_reader(
195
264
  source: AsyncIterable[bytes],
196
265
  decompressobj: Decompressor
197
266
  ) -> bytes:
267
+ """Reads a compressed stream and returns the decompressed bytes.
268
+
269
+ Args:
270
+ source (AsyncIterable[bytes]): The input stream.
271
+ decompressobj (Decompressor): The decompressor.
272
+
273
+ Returns:
274
+ bytes: The decompressed bytes.
275
+ """
198
276
  return await bytes_reader(compression_reader_adapter(source, decompressobj))
@@ -3,12 +3,8 @@
3
3
  from datetime import datetime, timedelta
4
4
  from typing import (
5
5
  Any,
6
- Dict,
7
- List,
8
6
  Mapping,
9
- MutableMapping,
10
- Optional,
11
- Union
7
+ MutableMapping
12
8
  )
13
9
 
14
10
  from .dates import parse_date
@@ -19,13 +15,13 @@ def encode_set_cookie(
19
15
  name: bytes,
20
16
  value: bytes,
21
17
  *,
22
- expires: Optional[datetime] = None,
23
- max_age: Optional[Union[int, timedelta]] = None,
24
- path: Optional[bytes] = None,
25
- domain: Optional[bytes] = None,
18
+ expires: datetime | None = None,
19
+ max_age: int | timedelta | None = None,
20
+ path: bytes | None = None,
21
+ domain: bytes | None = None,
26
22
  secure: bool = False,
27
23
  http_only: bool = False,
28
- same_site: Optional[bytes] = None
24
+ same_site: bytes | None = None
29
25
  ) -> bytes:
30
26
  """Encode set-cookie
31
27
 
@@ -45,14 +41,13 @@ def encode_set_cookie(
45
41
  same_site (Optional[bytes], optional): CORS directive. Defaults to None.
46
42
 
47
43
  Raises:
48
- RuntimeError: Raised if the __Secure- or __Host- was used without secure
49
- ValueError: If a date could not be parsed.
44
+ ValueError: Raised if the __Secure- or __Host- was used without secure
50
45
 
51
46
  Returns:
52
47
  bytes: The set-cookie header
53
48
  """
54
49
  if not secure and (name.startswith(b'__Secure-') or name.startswith(b'__Host-')):
55
- raise RuntimeError(
50
+ raise ValueError(
56
51
  'Keys starting __Secure- or __Host- require the secure directive'
57
52
  )
58
53
 
@@ -89,8 +84,8 @@ def encode_set_cookie(
89
84
  def decode_set_cookie(set_cookie: bytes) -> Mapping[str, Any]:
90
85
  """Decode a set-cookie header into a dictionary.
91
86
 
92
- The `max-age` value is represented as a `datatime.timedelta`.
93
- The `expires` value is represented as a `datetine.datetime`.
87
+ The `max-age` value is represented as a `datetime.timedelta`.
88
+ The `expires` value is represented as a `datetime.datetime`.
94
89
  The `secure` value is represented as a `bool`.
95
90
 
96
91
  Args:
@@ -101,26 +96,26 @@ def decode_set_cookie(set_cookie: bytes) -> Mapping[str, Any]:
101
96
  """
102
97
  i = iter(set_cookie.split(b';'))
103
98
  key, _, value = next(i).partition(b'=')
104
- result: Dict[str, Any] = {'name': key, 'value': value}
99
+ result: dict[str, Any] = {'name': key, 'value': value}
105
100
  for item in i:
106
101
  key, _, value = item.partition(b'=')
107
- name = key.lower().strip().decode('ascii')
108
- if name == 'secure':
109
- result['secure'] = True
110
- elif name == 'httponly':
111
- result['http_only'] = True
112
- elif name == 'expires':
113
- result['expires'] = parse_date(value.decode('ascii'))
114
- elif name == 'max-age':
115
- result['max_age'] = timedelta(seconds=int(value))
116
- elif name == 'samesite':
117
- result['same_site'] = value.decode('ascii')
118
- else:
119
- result[name] = value
102
+ match key.lower().strip():
103
+ case b'secure':
104
+ result['secure'] = True
105
+ case b'httponly':
106
+ result['http_only'] = True
107
+ case b'expires':
108
+ result['expires'] = parse_date(value.decode('ascii'))
109
+ case b'max-age':
110
+ result['max_age'] = timedelta(seconds=int(value))
111
+ case b'samesite':
112
+ result['same_site'] = value
113
+ case name:
114
+ result[name.decode('ascii')] = value
120
115
  return result
121
116
 
122
117
 
123
- def encode_cookies(cookies: Mapping[bytes, List[bytes]]) -> bytes:
118
+ def encode_cookies(cookies: Mapping[bytes, list[bytes]]) -> bytes:
124
119
  """Encode the cookie header
125
120
 
126
121
  Args:
@@ -136,16 +131,16 @@ def encode_cookies(cookies: Mapping[bytes, List[bytes]]) -> bytes:
136
131
  )
137
132
 
138
133
 
139
- def decode_cookies(cookies: bytes) -> Mapping[bytes, List[bytes]]:
134
+ def decode_cookies(cookies: bytes) -> Mapping[bytes, list[bytes]]:
140
135
  """Decode a cookie header
141
136
 
142
137
  Args:
143
138
  cookies (bytes): The header
144
139
 
145
140
  Returns:
146
- Mapping[bytes, List[bytes]]: The cookies
141
+ Mapping[bytes, list[bytes]]: The cookies
147
142
  """
148
- result: MutableMapping[bytes, List[bytes]] = dict()
143
+ result: MutableMapping[bytes, list[bytes]] = {}
149
144
  for morsel in cookies.rstrip(b'; ').split(b'; '):
150
145
  name, _, value = morsel.partition(b'=')
151
146
  result.setdefault(name, []).append(value)
@@ -156,12 +151,12 @@ def make_cookie(
156
151
  key: bytes,
157
152
  value: bytes,
158
153
  *,
159
- expires: Optional[Union[datetime, timedelta]] = None,
160
- path: Optional[bytes] = None,
161
- domain: Optional[bytes] = None,
154
+ expires: datetime | timedelta | None = None,
155
+ path: bytes | None = None,
156
+ domain: bytes | None = None,
162
157
  secure: bool = False,
163
158
  http_only: bool = False,
164
- same_site: Optional[bytes] = None
159
+ same_site: bytes | None = None
165
160
  ) -> bytes:
166
161
  """Make a set-cookie header
167
162
 
@@ -0,0 +1,7 @@
1
+ """Constants for date and time operations."""
2
+
3
+ DAY_NAMES = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
4
+ MONTH_NAMES = (
5
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
6
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
7
+ )
@@ -1,13 +1,12 @@
1
1
  """Date helpers"""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Optional
5
4
 
6
5
  from . import rfc_7231
7
6
  from . import rfc_850
8
7
 
9
8
 
10
- def try_parse_date(value: str) -> Optional[datetime]:
9
+ def try_parse_date(value: str) -> datetime | None:
11
10
  """Try to parse a date
12
11
 
13
12
  Args:
@@ -1,20 +1,18 @@
1
- """RFC7231"""
1
+ """RFC 7231"""
2
2
 
3
3
  from datetime import datetime, timezone
4
4
  import re
5
- from typing import Optional
5
+
6
+ from .constants import DAY_NAMES, MONTH_NAMES
6
7
 
7
8
  # pylint: disable=line-too-long
8
9
  # Date: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
9
10
  DATE_PATTERN = re.compile(
10
11
  r'^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$'
11
12
  )
12
- DAY_NAMES = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
13
- MONTH_NAMES = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
14
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
15
13
 
16
14
 
17
- def try_parse_date(value: str) -> Optional[datetime]:
15
+ def try_parse_date(value: str) -> datetime | None:
18
16
  """Parse a date according to RFC 7231, section 7.1.1.2: Date
19
17
 
20
18
  Args:
@@ -2,18 +2,16 @@
2
2
 
3
3
  from datetime import datetime, timezone
4
4
  import re
5
- from typing import Optional
5
+
6
+ from .constants import DAY_NAMES, MONTH_NAMES
6
7
 
7
8
  # 'Mon, 23-Mar-20 07:36:36 GMT'
8
9
  DATE_PATTERN = re.compile(
9
10
  r'^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$'
10
11
  )
11
- DAY_NAMES = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
12
- MONTH_NAMES = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
13
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
14
12
 
15
13
 
16
- def try_parse_date(value: str) -> Optional[datetime]:
14
+ def try_parse_date(value: str) -> datetime | None:
17
15
  """Parse a date according to RFC 850.
18
16
 
19
17
  e.g. 'Mon, 23-Mar-20 07:36:36 GMT'
@@ -58,7 +56,7 @@ def format_date(value: datetime) -> str:
58
56
  day=DAY_NAMES[time_tuple.tm_wday],
59
57
  mday=time_tuple.tm_mday,
60
58
  mon=MONTH_NAMES[time_tuple.tm_mon - 1],
61
- year=2000-time_tuple.tm_year,
59
+ year=time_tuple.tm_year % 100,
62
60
  hour=time_tuple.tm_hour,
63
61
  min=time_tuple.tm_min,
64
62
  sec=time_tuple.tm_sec