bareutils 4.0.2__tar.gz → 5.0.0__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 (32) hide show
  1. {bareutils-4.0.2 → bareutils-5.0.0}/LICENSE +1 -1
  2. bareutils-5.0.0/PKG-INFO +52 -0
  3. bareutils-5.0.0/README.md +15 -0
  4. bareutils-5.0.0/pyproject.toml +97 -0
  5. bareutils-5.0.0/setup.cfg +4 -0
  6. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/__init__.py +2 -0
  7. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/compression.py +89 -11
  8. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/cookies.py +25 -30
  9. bareutils-5.0.0/src/bareutils/dates/constants.py +7 -0
  10. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/dates/helpers.py +1 -2
  11. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/dates/rfc_7231.py +4 -6
  12. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/dates/rfc_850.py +4 -6
  13. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/header.py +488 -340
  14. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/unpacking.py +6 -6
  15. bareutils-5.0.0/src/bareutils.egg-info/PKG-INFO +52 -0
  16. bareutils-5.0.0/src/bareutils.egg-info/SOURCES.txt +26 -0
  17. bareutils-5.0.0/src/bareutils.egg-info/dependency_links.txt +1 -0
  18. bareutils-5.0.0/src/bareutils.egg-info/requires.txt +15 -0
  19. bareutils-5.0.0/src/bareutils.egg-info/top_level.txt +1 -0
  20. bareutils-5.0.0/tests/test_compression.py +35 -0
  21. bareutils-5.0.0/tests/test_cookies.py +147 -0
  22. bareutils-5.0.0/tests/test_header.py +895 -0
  23. bareutils-5.0.0/tests/test_response_code.py +21 -0
  24. bareutils-5.0.0/tests/test_streaming.py +34 -0
  25. bareutils-4.0.2/PKG-INFO +0 -34
  26. bareutils-4.0.2/README.md +0 -15
  27. bareutils-4.0.2/pyproject.toml +0 -30
  28. bareutils-4.0.2/setup.py +0 -26
  29. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/dates/__init__.py +0 -0
  30. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/py.typed +0 -0
  31. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/response_code.py +0 -0
  32. {bareutils-4.0.2 → bareutils-5.0.0/src}/bareutils/streaming.py +0 -0
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright [yyyy] [name of copyright owner]
189
+ Copyright 2026 Rob Blackbourn
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: bareutils
3
+ Version: 5.0.0
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://bareASGI.github.io/bareUtils
8
+ Project-URL: Repository, https://github.com/bareASGI/bareUtils
9
+ Project-URL: Issues, https://github.com/bareASGI/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: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: autopep8; extra == "dev"
25
+ Requires-Dist: coverage; extra == "dev"
26
+ Requires-Dist: mypy; extra == "dev"
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-asyncio; extra == "dev"
29
+ Requires-Dist: pylint; extra == "dev"
30
+ Requires-Dist: types-setuptools; extra == "dev"
31
+ Provides-Extra: docs
32
+ Requires-Dist: mkdocs; extra == "docs"
33
+ Requires-Dist: mkdocs-material; extra == "docs"
34
+ Requires-Dist: jetblack-markdown; extra == "docs"
35
+ Requires-Dist: mike; extra == "docs"
36
+ Dynamic: license-file
37
+
38
+ # bareutils
39
+
40
+ Utilities for [bareASGI](https://github.com/bareASGI/bareASGI)
41
+ and [bareClient](https://github.com/bareASGI/bareClient)
42
+ (read the [docs](https://bareASGI.github.io/bareUtils/)).
43
+
44
+ ## Installation
45
+
46
+ The package can be installed with pip.
47
+
48
+ ```bash
49
+ pip install bareutils
50
+ ```
51
+
52
+ This is a Python 3.12 and later package.
@@ -0,0 +1,15 @@
1
+ # bareutils
2
+
3
+ Utilities for [bareASGI](https://github.com/bareASGI/bareASGI)
4
+ and [bareClient](https://github.com/bareASGI/bareClient)
5
+ (read the [docs](https://bareASGI.github.io/bareUtils/)).
6
+
7
+ ## Installation
8
+
9
+ The package can be installed with pip.
10
+
11
+ ```bash
12
+ pip install bareutils
13
+ ```
14
+
15
+ This is a Python 3.12 and later package.
@@ -0,0 +1,97 @@
1
+ [project]
2
+ name = "bareutils"
3
+ version = "5.0.0"
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
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ ]
20
+ readme = "README.md"
21
+ license = "Apache-2.0"
22
+ license-files = [
23
+ "LICENSE"
24
+ ]
25
+ keywords = [
26
+ "asgi",
27
+ "web",
28
+ "framework",
29
+ "asyncio",
30
+ "http",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "autopep8",
36
+ "coverage",
37
+ "mypy",
38
+ "pytest",
39
+ "pytest-asyncio",
40
+ "pylint",
41
+ "types-setuptools",
42
+ ]
43
+ docs = [
44
+ "mkdocs",
45
+ "mkdocs-material",
46
+ "jetblack-markdown",
47
+ "mike",
48
+ ]
49
+
50
+ [project.urls]
51
+ Homepage = "https://bareASGI.github.io/bareUtils"
52
+ Repository = "https://github.com/bareASGI/bareUtils"
53
+ Issues = "https://github.com/bareASGI/bareUtils/issues"
54
+
55
+ [build-system]
56
+ requires = ["setuptools>=61.0"]
57
+ build-backend = "setuptools.build_meta"
58
+
59
+ # pylint
60
+ [tool.pylint.main]
61
+ py-version = "3.12"
62
+ jobs = 4
63
+
64
+ [tool.pylint.basic]
65
+ good-names = ["i", "j", "k", "ex", "Run", "_"]
66
+ bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"]
67
+
68
+ [tool.pylint."messages control"]
69
+ disable = [
70
+ "raw-checker-failed",
71
+ "bad-inline-option",
72
+ "locally-disabled",
73
+ "file-ignored",
74
+ "suppressed-message",
75
+ "useless-suppression",
76
+ "deprecated-pragma",
77
+ "use-symbolic-message-instead",
78
+ "use-implicit-booleaness-not-comparison-to-string",
79
+ "use-implicit-booleaness-not-comparison-to-zero",
80
+ "missing-module-docstring",
81
+ ]
82
+
83
+ # pytest
84
+ [tool.pytest.ini_options]
85
+ testpaths = [
86
+ "tests",
87
+ ]
88
+ asyncio_default_fixture_loop_scope = "session"
89
+ asyncio_default_test_loop_scope = "function"
90
+
91
+ # mypy
92
+ [tool.mypy]
93
+ files = [ "src", "tests" ]
94
+
95
+ [[tool.mypy.overrides]]
96
+ module = "pytest"
97
+ 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 direclty 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
 
@@ -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':
102
+ name = key.lower().strip()
103
+ if name == b'secure':
109
104
  result['secure'] = True
110
- elif name == 'httponly':
105
+ elif name == b'httponly':
111
106
  result['http_only'] = True
112
- elif name == 'expires':
107
+ elif name == b'expires':
113
108
  result['expires'] = parse_date(value.decode('ascii'))
114
- elif name == 'max-age':
109
+ elif name == b'max-age':
115
110
  result['max_age'] = timedelta(seconds=int(value))
116
- elif name == 'samesite':
117
- result['same_site'] = value.decode('ascii')
111
+ elif name == b'samesite':
112
+ result['same_site'] = value
118
113
  else:
119
- result[name] = value
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