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.
- {webchanges-3.24.1/webchanges.egg-info → webchanges-3.26.0}/PKG-INFO +21 -16
- {webchanges-3.24.1 → webchanges-3.26.0}/README.rst +11 -11
- {webchanges-3.24.1 → webchanges-3.26.0}/pyproject.toml +11 -6
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/__init__.py +3 -5
- webchanges-3.26.0/webchanges/_vendored/headers.py +313 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/_vendored/packaging_version.py +18 -23
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/cli.py +39 -14
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/command.py +75 -66
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/config.py +40 -35
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/differs.py +192 -104
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/filters.py +90 -162
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/handler.py +31 -23
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/jobs.py +152 -133
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/mailer.py +13 -16
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/main.py +2 -3
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/reporters.py +157 -79
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/storage.py +116 -43
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/storage_minidb.py +6 -6
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/util.py +36 -24
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/worker.py +5 -5
- {webchanges-3.24.1 → webchanges-3.26.0/webchanges.egg-info}/PKG-INFO +21 -16
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/SOURCES.txt +1 -1
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/requires.txt +4 -1
- webchanges-3.24.1/webchanges/_vendored/case_insensitive_dict.py +0 -101
- {webchanges-3.24.1 → webchanges-3.26.0}/LICENSE +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/MANIFEST.in +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/requirements.txt +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/setup.cfg +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/_vendored/__init__.py +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges/py.typed +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/dependency_links.txt +0 -0
- {webchanges-3.24.1 → webchanges-3.26.0}/webchanges.egg-info/entry_points.txt +0 -0
- {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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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:`
|
|
307
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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:`
|
|
145
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
'
|
|
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 = ['
|
|
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 = '
|
|
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,
|
|
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.
|
|
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})'
|