webchanges 3.24.1__tar.gz → 3.26.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 (33) hide show
  1. {webchanges-3.24.1/webchanges.egg-info → webchanges-3.26.0}/PKG-INFO +21 -16
  2. {webchanges-3.24.1 → webchanges-3.26.0}/README.rst +11 -11
  3. {webchanges-3.24.1 → webchanges-3.26.0}/pyproject.toml +11 -6
  4. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/__init__.py +3 -5
  5. webchanges-3.26.0/webchanges/_vendored/headers.py +313 -0
  6. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/_vendored/packaging_version.py +18 -23
  7. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/cli.py +39 -14
  8. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/command.py +75 -66
  9. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/config.py +40 -35
  10. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/differs.py +192 -104
  11. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/filters.py +90 -162
  12. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/handler.py +31 -23
  13. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/jobs.py +152 -133
  14. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/mailer.py +13 -16
  15. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/main.py +2 -3
  16. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/reporters.py +157 -79
  17. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/storage.py +116 -43
  18. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/storage_minidb.py +6 -6
  19. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/util.py +36 -24
  20. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/worker.py +5 -5
  21. {webchanges-3.24.1 → webchanges-3.26.0/webchanges.egg-info}/PKG-INFO +21 -16
  22. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/SOURCES.txt +1 -1
  23. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/requires.txt +4 -1
  24. webchanges-3.24.1/webchanges/_vendored/case_insensitive_dict.py +0 -101
  25. {webchanges-3.24.1 → webchanges-3.26.0}/LICENSE +0 -0
  26. {webchanges-3.24.1 → webchanges-3.26.0}/MANIFEST.in +0 -0
  27. {webchanges-3.24.1 → webchanges-3.26.0}/requirements.txt +0 -0
  28. {webchanges-3.24.1 → webchanges-3.26.0}/setup.cfg +0 -0
  29. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/_vendored/__init__.py +0 -0
  30. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/py.typed +0 -0
  31. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/dependency_links.txt +0 -0
  32. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/entry_points.txt +0 -0
  33. {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: webchanges
3
- Version: 3.24.1
3
+ Version: 3.26.0
4
4
  Summary: Check web (or command output) for changes since last run and notify. Anonymously alerts you of web changes, with
5
5
  Author-email: Mike Borsetti <mike+webchanges@borsetti.com>
6
6
  Maintainer-email: Mike Borsetti <mike+webchanges@borsetti.com>
@@ -80,6 +80,7 @@ Classifier: Environment :: Console
80
80
  Classifier: Topic :: Internet
81
81
  Classifier: Topic :: Internet :: WWW/HTTP
82
82
  Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
83
+ Classifier: Topic :: System :: Monitoring
83
84
  Classifier: Topic :: Utilities
84
85
  Classifier: Development Status :: 5 - Production/Stable
85
86
  Classifier: License :: OSI Approved :: MIT License
@@ -87,17 +88,19 @@ Classifier: License :: OSI Approved :: BSD License
87
88
  Classifier: Programming Language :: Python
88
89
  Classifier: Programming Language :: Python :: 3
89
90
  Classifier: Programming Language :: Python :: 3 :: Only
90
- Classifier: Programming Language :: Python :: 3.9
91
91
  Classifier: Programming Language :: Python :: 3.10
92
92
  Classifier: Programming Language :: Python :: 3.11
93
93
  Classifier: Programming Language :: Python :: 3.12
94
+ Classifier: Programming Language :: Python :: 3.13
94
95
  Classifier: Programming Language :: Python :: Implementation :: CPython
95
96
  Classifier: Operating System :: OS Independent
96
- Classifier: Natural Language :: English
97
+ Classifier: Environment :: Console
97
98
  Classifier: Intended Audience :: End Users/Desktop
98
99
  Classifier: Intended Audience :: System Administrators
99
100
  Classifier: Intended Audience :: Developers
100
- Requires-Python: >=3.9
101
+ Classifier: Natural Language :: English
102
+ Classifier: Typing :: Typed
103
+ Requires-Python: >=3.10
101
104
  Description-Content-Type: text/x-rst
102
105
  License-File: LICENSE
103
106
  Requires-Dist: colorama; os_name == "nt"
@@ -157,8 +160,10 @@ Provides-Extra: requests
157
160
  Requires-Dist: requests; extra == "requests"
158
161
  Provides-Extra: safe-password
159
162
  Requires-Dist: keyring; extra == "safe-password"
163
+ Provides-Extra: zstd
164
+ Requires-Dist: zstandard; extra == "zstd"
160
165
  Provides-Extra: all
161
- Requires-Dist: webchanges[beautify,bs4,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]; extra == "all"
166
+ Requires-Dist: webchanges[beautify,bs4,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp,zstd]; extra == "all"
162
167
 
163
168
  .. role:: underline
164
169
  :class: underline
@@ -204,7 +209,7 @@ Install **webchanges** |pypi_version| |format| |status| |security| with:
204
209
 
205
210
  Running in Docker
206
211
  =================
207
- **webchanges** can be run in a `Docker <https://www.docker.com/>`__ container. Please see `here
212
+ **webchanges** can be run in a `Docker <https://www.docker.com/>`__ container; please see `here
208
213
  <https://github.com/yubiuser/webchanges-docker>`__ for one such implementation.
209
214
 
210
215
 
@@ -245,14 +250,14 @@ execution, just run:
245
250
 
246
251
  webchanges
247
252
 
248
- **webchanges** does not include a scheduler. We recommend using a system scheduler to automatically run **webchanges**
253
+ **webchanges** does not include a scheduler. We recommend using a system scheduler to automatically run it
249
254
  periodically:
250
255
 
251
256
  - On Linux or macOS, you can use cron (if you have never used cron before, see
252
257
  `here <https://www.computerhope.com/unix/ucrontab.htm>`__); `crontab.guru <https://crontab.guru>`__ will build a
253
- schedule expression for you.
258
+ schedule expression for you;
254
259
  - On macOS, you can use `launchd <https://developer.apple
255
- .com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html>`__
260
+ .com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html>`__;
256
261
  - On Windows, you can use the built-in `Windows Task Scheduler
257
262
  <https://en.wikipedia.org/wiki/Windows_Task_Scheduler>`__.
258
263
 
@@ -294,28 +299,28 @@ easily upgrade to **webchanges** from the current version of **urlwatch** using
294
299
  (see `here <https://webchanges.readthedocs.io/en/stable/migration.html>`__) and benefit from many improvements,
295
300
  including:
296
301
 
297
- * Summary of changes in plain text using Generative AI, useful for long, boring, legal documents;
302
+ * Summary of changes in plain text using Generative AI, useful for long documents (e.g. legal);
298
303
  * Depicting changes to an image;
299
304
  * Element-by-element changes of JSON or XML data;
300
- * Much better `documentation <https://webchanges.readthedocs.io/>`__;
301
- * Many improvements to HTML reports, including:
305
+ * Much better `documentation <https://webchanges.readthedocs.io/>`__, easing implementation;
306
+ * Much clearer to HTML reports, which include:
302
307
 
303
308
  * Links that are `clickable <https://pypi.org/project/webchanges/>`__!
304
309
  * Retaining of original formatting such as **bolding / headers**, *italics*, :underline:`underlining`, list bullets
305
310
  (•) and indentation;
306
- * :additions:`Added` and :deletions:`deleted` lines clearly highlighted by color and strikethrough, and long lines
307
- that wrap around;
311
+ * Lines that are :additions:`added` and :deletions:`deleted` are clearly highlighted by color and strikethrough,
312
+ and long lines wrap around;
308
313
  * Correct rendering by email clients who override stylesheets (e.g. Gmail);
309
314
  * Other legibility improvements;
310
315
 
311
316
  * New filters such as `additions_only <https://webchanges.readthedocs.io/en/stable/diff_filters.html#additions-only>`__,
312
317
  which makes it easier to track content that was added without the distractions of the content that was deleted;
313
- * New command line arguments such as ``--errors`` to catch jobs that no longer work;
318
+ * New command line arguments, such as ``--errors`` (to catch jobs that no longer work);
314
319
  * More reliability and stability, including a ~30 percentage point increase in testing coverage;
315
320
  * Many other additions, refinements and fixes (see `detailed information
316
321
  <https://webchanges.readthedocs.io/en/stable/migration.html#upgrade-details>`__).
317
322
 
318
- Examples:
323
+ Example enhancements to HTML reporting:
319
324
 
320
325
  .. image:: https://raw.githubusercontent.com/mborsetti/webchanges/main/docs/html_diff_filters_example_1.png
321
326
  :width: 504
@@ -42,7 +42,7 @@ Install **webchanges** |pypi_version| |format| |status| |security| with:
42
42
 
43
43
  Running in Docker
44
44
  =================
45
- **webchanges** can be run in a `Docker <https://www.docker.com/>`__ container. Please see `here
45
+ **webchanges** can be run in a `Docker <https://www.docker.com/>`__ container; please see `here
46
46
  <https://github.com/yubiuser/webchanges-docker>`__ for one such implementation.
47
47
 
48
48
 
@@ -83,14 +83,14 @@ execution, just run:
83
83
 
84
84
  webchanges
85
85
 
86
- **webchanges** does not include a scheduler. We recommend using a system scheduler to automatically run **webchanges**
86
+ **webchanges** does not include a scheduler. We recommend using a system scheduler to automatically run it
87
87
  periodically:
88
88
 
89
89
  - On Linux or macOS, you can use cron (if you have never used cron before, see
90
90
  `here <https://www.computerhope.com/unix/ucrontab.htm>`__); `crontab.guru <https://crontab.guru>`__ will build a
91
- schedule expression for you.
91
+ schedule expression for you;
92
92
  - On macOS, you can use `launchd <https://developer.apple
93
- .com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html>`__
93
+ .com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html>`__;
94
94
  - On Windows, you can use the built-in `Windows Task Scheduler
95
95
  <https://en.wikipedia.org/wiki/Windows_Task_Scheduler>`__.
96
96
 
@@ -132,28 +132,28 @@ easily upgrade to **webchanges** from the current version of **urlwatch** using
132
132
  (see `here <https://webchanges.readthedocs.io/en/stable/migration.html>`__) and benefit from many improvements,
133
133
  including:
134
134
 
135
- * Summary of changes in plain text using Generative AI, useful for long, boring, legal documents;
135
+ * Summary of changes in plain text using Generative AI, useful for long documents (e.g. legal);
136
136
  * Depicting changes to an image;
137
137
  * Element-by-element changes of JSON or XML data;
138
- * Much better `documentation <https://webchanges.readthedocs.io/>`__;
139
- * Many improvements to HTML reports, including:
138
+ * Much better `documentation <https://webchanges.readthedocs.io/>`__, easing implementation;
139
+ * Much clearer to HTML reports, which include:
140
140
 
141
141
  * Links that are `clickable <https://pypi.org/project/webchanges/>`__!
142
142
  * Retaining of original formatting such as **bolding / headers**, *italics*, :underline:`underlining`, list bullets
143
143
  (•) and indentation;
144
- * :additions:`Added` and :deletions:`deleted` lines clearly highlighted by color and strikethrough, and long lines
145
- that wrap around;
144
+ * Lines that are :additions:`added` and :deletions:`deleted` are clearly highlighted by color and strikethrough,
145
+ and long lines wrap around;
146
146
  * Correct rendering by email clients who override stylesheets (e.g. Gmail);
147
147
  * Other legibility improvements;
148
148
 
149
149
  * New filters such as `additions_only <https://webchanges.readthedocs.io/en/stable/diff_filters.html#additions-only>`__,
150
150
  which makes it easier to track content that was added without the distractions of the content that was deleted;
151
- * New command line arguments such as ``--errors`` to catch jobs that no longer work;
151
+ * New command line arguments, such as ``--errors`` (to catch jobs that no longer work);
152
152
  * More reliability and stability, including a ~30 percentage point increase in testing coverage;
153
153
  * Many other additions, refinements and fixes (see `detailed information
154
154
  <https://webchanges.readthedocs.io/en/stable/migration.html#upgrade-details>`__).
155
155
 
156
- Examples:
156
+ Example enhancements to HTML reporting:
157
157
 
158
158
  .. image:: https://raw.githubusercontent.com/mborsetti/webchanges/main/docs/html_diff_filters_example_1.png
159
159
  :width: 504
@@ -19,7 +19,7 @@ description = """\
19
19
  Gen AI summaries (BETA).\
20
20
  """
21
21
  readme = { file = 'README.rst', content-type = 'text/x-rst' }
22
- requires-python = '>=3.9'
22
+ requires-python = '>=3.10'
23
23
  license = {file = 'LICENSE'}
24
24
  authors = [
25
25
  {name = 'Mike Borsetti', email = 'mike+webchanges@borsetti.com'},
@@ -33,6 +33,7 @@ classifiers = [
33
33
  'Topic :: Internet',
34
34
  'Topic :: Internet :: WWW/HTTP',
35
35
  'Topic :: Internet :: WWW/HTTP :: Indexing/Search',
36
+ 'Topic :: System :: Monitoring',
36
37
  'Topic :: Utilities',
37
38
  'Development Status :: 5 - Production/Stable',
38
39
  'License :: OSI Approved :: MIT License',
@@ -40,16 +41,18 @@ classifiers = [
40
41
  'Programming Language :: Python',
41
42
  'Programming Language :: Python :: 3',
42
43
  'Programming Language :: Python :: 3 :: Only',
43
- 'Programming Language :: Python :: 3.9',
44
44
  'Programming Language :: Python :: 3.10',
45
45
  'Programming Language :: Python :: 3.11',
46
46
  'Programming Language :: Python :: 3.12',
47
+ 'Programming Language :: Python :: 3.13',
47
48
  'Programming Language :: Python :: Implementation :: CPython',
48
49
  'Operating System :: OS Independent',
49
- 'Natural Language :: English',
50
+ 'Environment :: Console',
50
51
  'Intended Audience :: End Users/Desktop',
51
52
  'Intended Audience :: System Administrators',
52
53
  'Intended Audience :: Developers',
54
+ 'Natural Language :: English',
55
+ 'Typing :: Typed',
53
56
  ]
54
57
 
55
58
  [project.urls]
@@ -90,8 +93,9 @@ xmpp = ['aioxmpp']
90
93
  redis = ['redis']
91
94
  requests = ['requests']
92
95
  safe_password = ['keyring']
96
+ zstd = ['zstandard']
93
97
  all = [
94
- 'webchanges[use_browser,beautify,bs4,html5lib,ical2text,jq,ocr,pdf2text,pypdf_crypto,deepdiff_xml,imagediff,matrix,pushbullet,pushover,xmpp,redis,requests,safe_password]'
98
+ 'webchanges[use_browser,beautify,bs4,html5lib,ical2text,jq,ocr,pdf2text,pypdf_crypto,deepdiff_xml,imagediff,matrix,pushbullet,pushover,xmpp,redis,requests,safe_password,zstd]'
95
99
  ]
96
100
 
97
101
 
@@ -155,7 +159,7 @@ color_output = true
155
159
  [tool.black]
156
160
  # What's in here overrides the command-line options shown by running $ black --help.
157
161
  line_length = 120
158
- target_version = ['py39']
162
+ target_version = ['py310']
159
163
  skip_string_normalization = true
160
164
  extend_exclude = '/(\.idea|\.pytest_cache|\__pycache__|\venv.*|\webchanges.egg-info)/'
161
165
  color = true
@@ -305,11 +309,12 @@ exclude_lines = [
305
309
  log_auto_indent = true
306
310
  # Enable log display during test run (aka "live logging" https://docs.pytest.org/en/stable/logging.html#live-logs)
307
311
  log_cli = true
308
- minversion = '7.4.0'
312
+ minversion = '8.3.3'
309
313
  testpaths = ['tests']
310
314
 
311
315
  # the below is for pytest-asyncio (required due to Playwright)
312
316
  asyncio_mode = 'auto'
317
+ asyncio_default_fixture_loop_scope = 'function'
313
318
 
314
319
  # Adds pytest-cov functionality (see https://pytest-cov.readthedocs.io/en/latest/config.html)
315
320
  # Note: --cov moved to .github/workflows/ci-cd.yaml and tox.ini due to interference with PyCharm breakpoints (see
@@ -12,7 +12,7 @@ supported services. Can check the output of local commands as well.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- __min_python_version__ = (3, 9) # minimum version of Python required to run; supported until 5 October 2023
15
+ __min_python_version__ = (3, 10) # minimum version of Python required to run; supported until fall 2025
16
16
 
17
17
 
18
18
  __project_name__ = __package__
@@ -22,7 +22,7 @@ __project_name__ = __package__
22
22
  # * MINOR version when you add functionality in a backwards compatible manner, and
23
23
  # * MICRO or PATCH version when you make backwards compatible bug fixes. We no longer use '0'
24
24
  # If unsure on increments, use pkg_resources.parse_version to parse
25
- __version__ = '3.24.1'
25
+ __version__ = '3.26.0'
26
26
  __description__ = (
27
27
  'Check web (or command output) for changes since last run and notify.\n'
28
28
  '\n'
@@ -36,10 +36,8 @@ __code_url__ = f'https://github.com/mborsetti/{__project_name__}/'
36
36
  __docs_url__ = f'https://{__project_name__}.readthedocs.io/'
37
37
  __user_agent__ = f'{__project_name__}/{__version__} (+{__url__})'
38
38
 
39
- from typing import Union
40
39
 
41
-
42
- def init_data() -> dict[str, Union[str, tuple]]:
40
+ def init_data() -> dict[str, str | tuple]:
43
41
  """Returns dict of globals (used in testing).
44
42
 
45
43
  :returns: dict of globals()
@@ -0,0 +1,313 @@
1
+ """
2
+ Vendored version of httpx.Headers class from httpx v0.27.0 released on 21-Feb-24
3
+ https://github.com/encode/httpx/releases/tag/0.27.0.
4
+
5
+ Allows us to load this class in case httpx isn't installed.
6
+
7
+ See https://github.com/psf/requests and https://github.com/encode/httpx/blob/master/httpx/_models.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import (
13
+ Any,
14
+ AnyStr,
15
+ ItemsView,
16
+ Iterable,
17
+ Iterator,
18
+ KeysView,
19
+ Mapping,
20
+ MutableMapping,
21
+ Sequence,
22
+ TypeAlias,
23
+ ValuesView,
24
+ )
25
+
26
+ HeaderTypes: TypeAlias = (
27
+ 'Headers' | Mapping[str, str] | Mapping[bytes, bytes] | Sequence[tuple[str, str]] | Sequence[tuple[bytes, bytes]]
28
+ )
29
+
30
+
31
+ def normalize_header_key(
32
+ value: str | bytes,
33
+ lower: bool,
34
+ encoding: str | None = None,
35
+ ) -> bytes:
36
+ """
37
+ Coerce str/bytes into a strictly byte-wise HTTP header key.
38
+ """
39
+ if isinstance(value, bytes):
40
+ bytes_value = value
41
+ else:
42
+ bytes_value = value.encode(encoding or 'ascii')
43
+
44
+ return bytes_value.lower() if lower else bytes_value
45
+
46
+
47
+ def normalize_header_value(value: str | bytes, encoding: str | None = None) -> bytes:
48
+ """
49
+ Coerce str/bytes into a strictly byte-wise HTTP header value.
50
+ """
51
+ if isinstance(value, bytes):
52
+ return value
53
+ return value.encode(encoding or 'ascii')
54
+
55
+
56
+ SENSITIVE_HEADERS = {'authorization', 'proxy-authorization'}
57
+
58
+
59
+ def obfuscate_sensitive_headers(
60
+ items: Iterable[tuple[AnyStr, AnyStr]],
61
+ ) -> Iterator[tuple[AnyStr, AnyStr]]:
62
+ for k, v in items:
63
+ if to_str(k.lower()) in SENSITIVE_HEADERS:
64
+ v = to_bytes_or_str('[secure]', match_type_of=v)
65
+ yield k, v
66
+
67
+
68
+ def to_str(value: str | bytes, encoding: str = 'utf-8') -> str:
69
+ return value if isinstance(value, str) else value.decode(encoding)
70
+
71
+
72
+ def to_bytes_or_str(value: str, match_type_of: AnyStr) -> AnyStr:
73
+ return value if isinstance(match_type_of, str) else value.encode()
74
+
75
+
76
+ class Headers(MutableMapping[str, str]):
77
+ """
78
+ HTTP headers, as a case-insensitive multi-dict.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ headers: HeaderTypes | None = None,
84
+ encoding: str | None = None,
85
+ ) -> None:
86
+ if headers is None:
87
+ self._list: list[tuple[bytes, bytes, bytes]] = []
88
+ elif isinstance(headers, Headers):
89
+ self._list = list(headers._list)
90
+ elif isinstance(headers, Mapping):
91
+ self._list = [
92
+ (
93
+ normalize_header_key(k, lower=False, encoding=encoding),
94
+ normalize_header_key(k, lower=True, encoding=encoding),
95
+ normalize_header_value(v, encoding),
96
+ )
97
+ for k, v in headers.items()
98
+ ]
99
+ else:
100
+ self._list = [
101
+ (
102
+ normalize_header_key(k, lower=False, encoding=encoding),
103
+ normalize_header_key(k, lower=True, encoding=encoding),
104
+ normalize_header_value(v, encoding),
105
+ )
106
+ for k, v in headers
107
+ ]
108
+
109
+ self._encoding = encoding
110
+
111
+ @property
112
+ def encoding(self) -> str:
113
+ """
114
+ Header encoding is mandated as ascii, but we allow fallbacks to utf-8
115
+ or iso-8859-1.
116
+ """
117
+ if self._encoding is None:
118
+ for encoding in ['ascii', 'utf-8']:
119
+ for key, value in self.raw:
120
+ try:
121
+ key.decode(encoding)
122
+ value.decode(encoding)
123
+ except UnicodeDecodeError:
124
+ break
125
+ else:
126
+ # The else block runs if 'break' did not occur, meaning
127
+ # all values fitted the encoding.
128
+ self._encoding = encoding
129
+ break
130
+ else:
131
+ # The ISO-8859-1 encoding covers all 256 code points in a byte,
132
+ # so will never raise decode errors.
133
+ self._encoding = 'iso-8859-1'
134
+ return self._encoding
135
+
136
+ @encoding.setter
137
+ def encoding(self, value: str) -> None:
138
+ self._encoding = value
139
+
140
+ @property
141
+ def raw(self) -> list[tuple[bytes, bytes]]:
142
+ """
143
+ Returns a list of the raw header items, as byte pairs.
144
+ """
145
+ return [(raw_key, value) for raw_key, _, value in self._list]
146
+
147
+ def keys(self) -> KeysView[str]:
148
+ return {key.decode(self.encoding): None for _, key, value in self._list}.keys()
149
+
150
+ def values(self) -> ValuesView[str]:
151
+ values_dict: dict[str, str] = {}
152
+ for _, key, value in self._list:
153
+ str_key = key.decode(self.encoding)
154
+ str_value = value.decode(self.encoding)
155
+ if str_key in values_dict:
156
+ values_dict[str_key] += f', {str_value}'
157
+ else:
158
+ values_dict[str_key] = str_value
159
+ return values_dict.values()
160
+
161
+ def items(self) -> ItemsView[str, str]:
162
+ """
163
+ Return `(key, value)` items of headers. Concatenate headers
164
+ into a single comma separated value when a key occurs multiple times.
165
+ """
166
+ values_dict: dict[str, str] = {}
167
+ for _, key, value in self._list:
168
+ str_key = key.decode(self.encoding)
169
+ str_value = value.decode(self.encoding)
170
+ if str_key in values_dict:
171
+ values_dict[str_key] += f', {str_value}'
172
+ else:
173
+ values_dict[str_key] = str_value
174
+ return values_dict.items()
175
+
176
+ def multi_items(self) -> list[tuple[str, str]]:
177
+ """
178
+ Return a list of `(key, value)` pairs of headers. Allow multiple
179
+ occurrences of the same key without concatenating into a single
180
+ comma separated value.
181
+ """
182
+ return [(key.decode(self.encoding), value.decode(self.encoding)) for _, key, value in self._list]
183
+
184
+ def get(self, key: str, default: Any = None) -> Any:
185
+ """
186
+ Return a header value. If multiple occurrences of the header occur
187
+ then concatenate them together with commas.
188
+ """
189
+ try:
190
+ return self[key]
191
+ except KeyError:
192
+ return default
193
+
194
+ def get_list(self, key: str, split_commas: bool = False) -> list[str]:
195
+ """
196
+ Return a list of all header values for a given key.
197
+ If `split_commas=True` is passed, then any comma separated header
198
+ values are split into multiple return strings.
199
+ """
200
+ get_header_key = key.lower().encode(self.encoding)
201
+
202
+ values = [
203
+ item_value.decode(self.encoding)
204
+ for _, item_key, item_value in self._list
205
+ if item_key.lower() == get_header_key
206
+ ]
207
+
208
+ if not split_commas:
209
+ return values
210
+
211
+ split_values = []
212
+ for value in values:
213
+ split_values.extend([item.strip() for item in value.split(',')])
214
+ return split_values
215
+
216
+ def update(self, headers: HeaderTypes | None = None) -> None: # type: ignore[override]
217
+ headers = Headers(headers)
218
+ for key in headers.keys():
219
+ if key in self:
220
+ self.pop(key)
221
+ self._list.extend(headers._list)
222
+
223
+ def copy(self) -> Headers:
224
+ return Headers(self, encoding=self.encoding)
225
+
226
+ def __getitem__(self, key: str) -> str:
227
+ """
228
+ Return a single header value.
229
+
230
+ If there are multiple headers with the same key, then we concatenate
231
+ them with commas. See: https://tools.ietf.org/html/rfc7230#section-3.2.2
232
+ """
233
+ normalized_key = key.lower().encode(self.encoding)
234
+
235
+ items = [
236
+ header_value.decode(self.encoding)
237
+ for _, header_key, header_value in self._list
238
+ if header_key == normalized_key
239
+ ]
240
+
241
+ if items:
242
+ return ', '.join(items)
243
+
244
+ raise KeyError(key)
245
+
246
+ def __setitem__(self, key: str, value: str) -> None:
247
+ """
248
+ Set the header `key` to `value`, removing any duplicate entries.
249
+ Retains insertion order.
250
+ """
251
+ set_key = key.encode(self._encoding or 'utf-8')
252
+ set_value = value.encode(self._encoding or 'utf-8')
253
+ lookup_key = set_key.lower()
254
+
255
+ found_indexes = [idx for idx, (_, item_key, _) in enumerate(self._list) if item_key == lookup_key]
256
+
257
+ for idx in reversed(found_indexes[1:]):
258
+ del self._list[idx]
259
+
260
+ if found_indexes:
261
+ idx = found_indexes[0]
262
+ self._list[idx] = (set_key, lookup_key, set_value)
263
+ else:
264
+ self._list.append((set_key, lookup_key, set_value))
265
+
266
+ def __delitem__(self, key: str) -> None:
267
+ """
268
+ Remove the header `key`.
269
+ """
270
+ del_key = key.lower().encode(self.encoding)
271
+
272
+ pop_indexes = [idx for idx, (_, item_key, _) in enumerate(self._list) if item_key.lower() == del_key]
273
+
274
+ if not pop_indexes:
275
+ raise KeyError(key)
276
+
277
+ for idx in reversed(pop_indexes):
278
+ del self._list[idx]
279
+
280
+ def __contains__(self, key: Any) -> bool:
281
+ header_key = key.lower().encode(self.encoding)
282
+ return header_key in [key for _, key, _ in self._list]
283
+
284
+ def __iter__(self) -> Iterator[Any]:
285
+ return iter(self.keys())
286
+
287
+ def __len__(self) -> int:
288
+ return len(self._list)
289
+
290
+ def __eq__(self, other: Any) -> bool:
291
+ try:
292
+ other_headers = Headers(other)
293
+ except ValueError:
294
+ return False
295
+
296
+ self_list = [(key, value) for _, key, value in self._list]
297
+ other_list = [(key, value) for _, key, value in other_headers._list]
298
+ return sorted(self_list) == sorted(other_list)
299
+
300
+ def __repr__(self) -> str:
301
+ class_name = self.__class__.__name__
302
+
303
+ encoding_str = ''
304
+ if self.encoding != 'ascii':
305
+ encoding_str = f', encoding={self.encoding!r}'
306
+
307
+ as_list = list(obfuscate_sensitive_headers(self.multi_items()))
308
+ as_dict = dict(as_list)
309
+
310
+ no_duplicate_keys = len(as_dict) == len(as_list)
311
+ if no_duplicate_keys:
312
+ return f'{class_name}({as_dict!r}{encoding_str})'
313
+ return f'{class_name}({as_list!r}{encoding_str})'