From 04a20c79ff46461937b400ad6436ddf50744c946 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 27 Jan 2025 07:50:47 +0100 Subject: [PATCH 1/5] :sparkle: support async models in Niquests / urllib3-future --- kiss_headers/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kiss_headers/api.py b/kiss_headers/api.py index b98416c..2fd2ac8 100644 --- a/kiss_headers/api.py +++ b/kiss_headers/api.py @@ -58,7 +58,7 @@ def parse_it(raw_headers: Any) -> Headers: r = extract_class_name(type(raw_headers)) if r: - if r in ["requests.models.Response", "niquests.models.Response"]: + if r in ["requests.models.Response", "niquests.models.Response", "niquests.models.AsyncResponse"]: headers = [] for header_name in raw_headers.raw.headers: for header_content in raw_headers.raw.headers.getlist(header_name): @@ -66,6 +66,9 @@ def parse_it(raw_headers: Any) -> Headers: elif r in [ "httpx._models.Response", "urllib3.response.HTTPResponse", + "urllib3._async.response.AsyncHTTPResponse", + "urllib3_future.response.HTTPResponse", + "urllib3_future._async.response.AsyncHTTPResponse", ]: # pragma: no cover headers = raw_headers.headers.items() From 65188f76f98d558b16ce8883b679d585bd7c1d92 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 27 Jan 2025 07:51:17 +0100 Subject: [PATCH 2/5] :wrench: add explicit support for Python 3.13 --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4a33d75..4dab02d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,10 @@ license-files = { paths = ["LICENSE"] } license = "MIT" keywords = ["headers", "http", "mail", "text", "imap", "header", "https", "imap4"] authors = [ - {name = "Ahmed R. TAHRI", email="ahmed.tahri@cloudnursery.dev"}, + {name = "Ahmed R. TAHRI", email="tahri.ahmed@proton.me"}, ] maintainers = [ - {name = "Ahmed R. TAHRI", email="ahmed.tahri@cloudnursery.dev"}, + {name = "Ahmed R. TAHRI", email="tahri.ahmed@proton.me"}, ] classifiers = [ "License :: OSI Approved :: MIT License", @@ -32,6 +32,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", "Topic :: Utilities", "Programming Language :: Python :: Implementation :: PyPy", From ce032f2570f674e78b78a98244a99895dcd9b931 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 27 Jan 2025 07:51:34 +0100 Subject: [PATCH 3/5] :fire: remove old guide UPGRADE.md v1 -> v2 --- UPGRADE.md | 42 ------------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 UPGRADE.md diff --git a/UPGRADE.md b/UPGRADE.md deleted file mode 100644 index 88e9f9b..0000000 --- a/UPGRADE.md +++ /dev/null @@ -1,42 +0,0 @@ -Migrate from v1 to v2 ------------------------- - -The API remain stable, the biggest change is : - - - Header that contain multiple entries (usually separated with a coma) are now exploded into multiple header object. - -Before : -```python -headers = parse_it( - """Host: developer.mozilla.org -User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 -Accept-Language: en-US,en;q=0.5 -Accept-Encoding: gzip, deflate, br""") - -print( - headers.accept.content -) # Would output : 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' -``` -Now : - -```python -headers = parse_it( - """Host: developer.mozilla.org -User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 -Accept-Language: en-US,en;q=0.5 -Accept-Encoding: gzip, deflate, br""") - -for accept in headers.accept: - print(accept.content, "has qualifier", accept.has("q")) - -# Would output : -# text/html has qualifier False -# application/xhtml+xml has qualifier False -# application/xml;q=0.9 has qualifier True -# */*;q=0.8 has qualifier True -``` - -Sometime a single header can contain multiple entries, usually separated by a coma. Now kiss-headers always separate them to create -multiple object. From 07215c09c8ee4a7bbb9fa6c1396d5a671cd59a00 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 27 Jan 2025 07:51:50 +0100 Subject: [PATCH 4/5] :bookmark: bump version to 2.5.0 --- kiss_headers/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiss_headers/version.py b/kiss_headers/version.py index fa33b69..d162033 100644 --- a/kiss_headers/version.py +++ b/kiss_headers/version.py @@ -2,5 +2,5 @@ Expose version """ -__version__ = "2.4.3" +__version__ = "2.5.0" VERSION = __version__.split(".") From bdb823309aec518eec7026d59e2dc58333fa91ca Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 27 Jan 2025 08:30:27 +0100 Subject: [PATCH 5/5] :art: restructure the whole project and ensure best practices --- .coveragerc | 25 ++ .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/dependabot.yml | 18 + .github/workflows/lint.yml | 34 +- .github/workflows/publish.yml | 14 +- .github/workflows/run-tests.yml | 61 +++- .pre-commit-config.yaml | 25 ++ CONTRIBUTING.md | 6 +- LICENSE | 2 +- README.md | 8 +- dev-requirements.txt | 10 +- kiss_headers/py.typed | 1 - noxfile.py | 58 ++++ pyproject.toml | 38 ++- scripts/check | 14 - scripts/install | 17 - scripts/lint | 11 - setup.cfg | 3 - .../kiss_headers}/__init__.py | 2 + {kiss_headers => src/kiss_headers}/api.py | 34 +- {kiss_headers => src/kiss_headers}/builder.py | 310 +++++++++--------- {kiss_headers => src/kiss_headers}/models.py | 293 +++++++---------- src/kiss_headers/py.typed | 0 .../kiss_headers}/serializer.py | 10 +- .../kiss_headers}/structures.py | 14 +- {kiss_headers => src/kiss_headers}/utils.py | 42 +-- {kiss_headers => src/kiss_headers}/version.py | 2 + tests/test_attributes.py | 2 + tests/test_builder.py | 8 +- tests/test_builder_create.py | 2 + tests/test_case_insensible_dict.py | 2 + tests/test_explain.py | 2 + tests/test_from_unknown_mapping.py | 2 + tests/test_header_operation.py | 2 + tests/test_header_order.py | 2 + tests/test_headers.py | 4 +- tests/test_headers_from_string.py | 16 +- tests/test_headers_operation.py | 2 + tests/test_headers_reserved_keyword.py | 2 + tests/test_polymorphic.py | 4 +- tests/test_serializer.py | 6 +- tests/test_with_http_request.py | 11 +- 42 files changed, 598 insertions(+), 523 deletions(-) create mode 100644 .coveragerc create mode 100644 .github/dependabot.yml create mode 100644 .pre-commit-config.yaml delete mode 100644 kiss_headers/py.typed create mode 100644 noxfile.py delete mode 100755 scripts/check delete mode 100755 scripts/install delete mode 100755 scripts/lint delete mode 100644 setup.cfg rename {kiss_headers => src/kiss_headers}/__init__.py (94%) rename {kiss_headers => src/kiss_headers}/api.py (88%) rename {kiss_headers => src/kiss_headers}/builder.py (86%) rename {kiss_headers => src/kiss_headers}/models.py (84%) create mode 100644 src/kiss_headers/py.typed rename {kiss_headers => src/kiss_headers}/serializer.py (86%) rename {kiss_headers => src/kiss_headers}/structures.py (90%) rename {kiss_headers => src/kiss_headers}/utils.py (93%) rename {kiss_headers => src/kiss_headers}/version.py (68%) diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..6f8c0a9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = + kiss_headers +# Needed for Python 3.11 and lower +disable_warnings = no-sysmon + +[paths] +source = + src/kiss_headers + */kiss_headers + *\kiss_headers + +[report] +exclude_lines = + except ModuleNotFoundError: + except ImportError: + pass + import + raise NotImplementedError + .* # Platform-specific.* + .*:.* # Python \d.* + .* # Abstract + .* # Defensive: + if (?:typing.)?TYPE_CHECKING: + ^\s*?\.\.\.\s*$ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c982577..7fd3bdd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@ ## Pull Request ### My patch is about -- [ ] :bug: Bugfix +- [ ] :bug: Bugfix - [ ] :arrow_up: Improvement - [ ] :pencil: Documentation - [ ] :heavy_check_mark: Tests diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e43d828 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + ignore: + # Ignore all patch releases as we can manually + # upgrade if we run into a bug and need a fix. + - dependency-name: "*" + update-types: ["version-update:semver-patch"] + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + ignore: + - dependency-name: "*" + update-types: [ "version-update:semver-patch" ] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3878f41..98831ae 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,33 +9,15 @@ on: jobs: lint: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - python-version: ["3.10"] - os: [ubuntu-latest] + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: 3.x - name: Install dependencies - run: | - pip install -U pip build wheel - pip install -r dev-requirements.txt - - name: Install the package - run: | - pip install . - - name: Type checking (Mypy) - run: | - mypy kiss_headers - - name: Import sorting check (isort) - run: | - isort --check kiss_headers - - name: Code format (Black) - run: | - black --check --diff kiss_headers + run: pip install nox + - name: Run pre-commit checks + run: nox -s lint diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc49679..c27399b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,10 +17,10 @@ jobs: steps: - name: "Checkout repository" - uses: "actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608" + uses: "actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871" - name: "Setup Python" - uses: "actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1" + uses: "actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b" with: python-version: "3.x" @@ -38,7 +38,7 @@ jobs: cd dist && echo "::set-output name=hashes::$(sha256sum * | base64 -w0)" - name: "Upload dists" - uses: "actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce" + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 with: name: "dist" path: "dist/" @@ -51,7 +51,7 @@ jobs: actions: read contents: write id-token: write # Needed to access the workflow's OIDC identity. - uses: "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0" + uses: "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0" with: base64-subjects: "${{ needs.build.outputs.hashes }}" upload-assets: true @@ -62,7 +62,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/') environment: name: pypi - url: https://pypi.org/p/kiss-headers + url: https://pypi.org/p/niquests needs: ["build", "provenance"] permissions: contents: write @@ -71,7 +71,7 @@ jobs: steps: - name: "Download dists" - uses: "actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a" + uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e with: name: "dist" path: "dist/" @@ -83,4 +83,4 @@ jobs: gh release upload ${{ github.ref_name }} dist/* --repo ${{ github.repository }} - name: "Publish dists to PyPI" - uses: "pypa/gh-action-pypi-publish@f8c70e705ffc13c3b4d1221169b84f12a75d6ca8" # v1.8.8 + uses: "pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70" # v1.12.3 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4723812..239925b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,28 +9,61 @@ on: jobs: tests: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] - os: [ubuntu-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies - run: | - pip install -U pip build wheel - pip install -r dev-requirements.txt - - name: Install the package - run: | - pip install . + run: pip install nox - name: Run tests - run: | - pytest - - uses: codecov/codecov-action@v1 + run: nox -s test-${{ matrix.python-version }} + - name: "Upload artifact" + uses: "actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808" + with: + name: coverage-data-${{ matrix.python-version }} + path: ".coverage.*" + if-no-files-found: error + + coverage: + if: always() + runs-on: "ubuntu-latest" + needs: tests + steps: + - name: "Checkout repository" + uses: "actions/checkout@d632683dd7b4114ad314bca15554477dd762a938" + + - name: "Setup Python" + uses: "actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b" + with: + python-version: "3.x" + + - name: "Install coverage" + run: "python -m pip install --upgrade coverage" + + - name: "Download artifact" + uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 + with: + pattern: coverage-data* + merge-multiple: true + + - name: "Combine & check coverage" + run: | + python -m coverage combine + python -m coverage html --skip-covered --skip-empty + python -m coverage report --ignore-errors --show-missing --fail-under=77 + + - name: "Upload report" + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 + with: + name: coverage-report + path: htmlcov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0681641 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +exclude: 'docs/' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.9.1 + hooks: + # Run the linter. + - id: ruff + args: [ --fix, --target-version=py37 ] + # Run the formatter. + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [ --check-untyped-defs ] + exclude: 'noxfile.py|tests/' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8be390b..cd00490 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ # Contribution Guidelines -If you’re reading this, you’re probably interested in contributing to **kiss-headers**. -Thank you very much! Open source projects thrive based on the support they receive from others, +If you’re reading this, you’re probably interested in contributing to **kiss-headers**. +Thank you very much! Open source projects thrive based on the support they receive from others, and the fact that you’re even considering contributing to this project is very generous of you. ## Questions -The GitHub issue tracker is for *bug reports* and *feature requests*. +The GitHub issue tracker is for *bug reports* and *feature requests*. Questions are allowed only when no answer are provided in docs. ## Good Bug Reports diff --git a/LICENSE b/LICENSE index f1995a7..b335f2a 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index e3841b7..8c3a982 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Plus all the features that you would expect from handling headers... ### ✨ Installation Whatever you like, use `pipenv` or `pip`, it simply works. Requires Python 3.7+ installed. -```sh +```sh pip install kiss-headers --upgrade ``` @@ -99,9 +99,9 @@ from requests import get from kiss_headers import Headers, UserAgent, Referer, UpgradeInsecureRequests, Accept, AcceptLanguage, CustomHeader class CustomHeaderXyz(CustomHeader): - + __squash__ = False - + def __init__(self, charset: str = "utf-8"): super().__init__("hello", charset=charset) @@ -366,7 +366,7 @@ If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a" Cache-Control: max-age="0" ``` -See the complete list of available header class in the full documentation. +See the complete list of available header class in the full documentation. Also, you can create your own custom header object using the class `kiss_headers.CustomHeader`. ## 📜 Documentation diff --git a/dev-requirements.txt b/dev-requirements.txt index de7c154..6ca1348 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,9 +1,3 @@ requests>=2.10 -black -pytest -pytest-cov -codecov -isort -mypy -mkdocs -mkdocs-material +pytest>=2.8.0,<=7.4.4 +coverage>=7.2.7,<7.7 diff --git a/kiss_headers/py.typed b/kiss_headers/py.typed deleted file mode 100644 index 8b13789..0000000 --- a/kiss_headers/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..65b5a91 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import os + +import nox + + +@nox.session( + python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy"] +) +def test(session: nox.Session) -> None: + # Install deps and the package itself. + session.install("-U", "pip", "setuptools", silent=False) + session.install("-r", "dev-requirements.txt", silent=False) + + session.install(".", silent=False) + + # Show the pip version. + session.run("pip", "--version") + # Print the Python version and bytesize. + session.run("python", "--version") + + # Inspired from https://hynek.me/articles/ditch-codecov-python/ + # We use parallel mode and then combine in a later CI step + session.run( + "python", + "-m", + "coverage", + "run", + "--parallel-mode", + "-m", + "pytest", + "-v", + "-ra", + f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}", + "--tb=native", + "--durations=10", + "--strict-config", + "--strict-markers", + *session.posargs, + env={ + "PYTHONWARNINGS": "always::DeprecationWarning", + "COVERAGE_CORE": "sysmon", + "PY_IGNORE_IMPORTMISMATCH": "1", + }, + ) + + +@nox.session() +def format(session: nox.Session) -> None: + """Run code formatters.""" + lint(session) + + +@nox.session +def lint(session: nox.Session) -> None: + session.install("pre-commit") + session.run("pre-commit", "run", "--all-files") diff --git a/pyproject.toml b/pyproject.toml index 4dab02d..5d635e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,40 +46,44 @@ dynamic = ["version"] "Issue tracker" = "https://github.com/jawah/kiss-headers/issues" [tool.hatch.version] -path = "kiss_headers/version.py" +path = "src/kiss_headers/version.py" [tool.hatch.build.targets.sdist] include = [ "/docs", - "/kiss_headers", + "/src", "/tests", "/dev-requirements.txt", "/README.md", "/LICENSE", - "/setup.cfg", ] [tool.hatch.build.targets.wheel] packages = [ - "kiss_headers/", + "src/kiss_headers/", ] -[tool.isort] -profile = "black" -src_paths = ["kiss_headers", "tests"] -honor_noqa = true -combine_as_imports = true -force_grid_wrap = 0 -include_trailing_comma = true -known_first_party = "kiss_headers,tests" -line_length = 88 -multi_line_output = 3 - [tool.pytest.ini_options] -addopts = "--cov=kiss_headers --doctest-modules --cov-report=term-missing -rxXs" +addopts = "--doctest-modules -rxXs" doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS" minversion = "6.2" -testpaths = ["tests", "kiss_headers"] +testpaths = ["tests", "src/kiss_headers"] +filterwarnings = [ + "error", +] [tool.mypy] disallow_untyped_defs = true + +[tool.ruff.lint] +ignore = ["E501", "E203", "E721"] +select = [ + "E", # pycodestyle + "F", # Pyflakes + "W", # pycodestyle + "I", # isort + "U", # pyupgrade +] + +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] diff --git a/scripts/check b/scripts/check deleted file mode 100755 index 51e2185..0000000 --- a/scripts/check +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -e - -export PREFIX="" -if [ -d 'venv' ] ; then - export PREFIX="venv/bin/" -fi -export SOURCE_FILES="kiss_headers tests" - -set -x - -${PREFIX}black --check --diff --target-version=py37 $SOURCE_FILES -${PREFIX}mypy kiss_headers -${PREFIX}isort --check --diff --project=kiss_headers $SOURCE_FILES -${PREFIX}pytest diff --git a/scripts/install b/scripts/install deleted file mode 100755 index ceadfd1..0000000 --- a/scripts/install +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -e - -export PREFIX="venv/bin/" - -set -x - -if [ -d 'venv' ] ; then - echo "Python virtual env already exists." - ${PREFIX}python --version -else - python -m venv venv -fi - -${PREFIX}python -m pip install -U pip build wheel -${PREFIX}python -m pip install -r dev-requirements.txt - -set +x diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index 516e7ec..0000000 --- a/scripts/lint +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -e - -export PREFIX="" -if [ -d 'venv' ] ; then - export PREFIX="venv/bin/" -fi -export SOURCE_FILES="kiss_headers tests" - -set -x -${PREFIX}black --target-version=py37 $SOURCE_FILES -${PREFIX}isort --project=kiss_headers $SOURCE_FILES diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 131a4f8..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -ignore = W503, E203, B305, E501, F401, E128, E402, E731, F821, Q000 -max-line-length = 88 diff --git a/kiss_headers/__init__.py b/src/kiss_headers/__init__.py similarity index 94% rename from kiss_headers/__init__.py rename to src/kiss_headers/__init__.py index a6f30c8..a0cb8f5 100644 --- a/kiss_headers/__init__.py +++ b/src/kiss_headers/__init__.py @@ -36,6 +36,8 @@ :license: MIT, see LICENSE for more details. """ +from __future__ import annotations + from .api import dumps, explain, get_polymorphic, parse_it from .builder import ( Accept, diff --git a/kiss_headers/api.py b/src/kiss_headers/api.py similarity index 88% rename from kiss_headers/api.py rename to src/kiss_headers/api.py index 2fd2ac8..898fd89 100644 --- a/kiss_headers/api.py +++ b/src/kiss_headers/api.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from copy import deepcopy from email.message import Message from email.parser import HeaderParser from io import BufferedReader, RawIOBase -from json import dumps as json_dumps, loads as json_loads -from typing import Any, Iterable, List, Mapping, Optional, Tuple, Type, TypeVar, Union +from json import dumps as json_dumps +from json import loads as json_loads +from typing import Any, Iterable, Mapping, TypeVar from .builder import CustomHeader from .models import Header, Headers @@ -37,7 +40,7 @@ def parse_it(raw_headers: Any) -> Headers: if isinstance(raw_headers, Headers): return deepcopy(raw_headers) - headers: Optional[Iterable[Tuple[Union[str, bytes], Union[str, bytes]]]] = None + headers: Iterable[tuple[str | bytes, str | bytes]] | None = None if isinstance(raw_headers, str): if raw_headers.startswith("{") and raw_headers.endswith("}"): @@ -58,7 +61,11 @@ def parse_it(raw_headers: Any) -> Headers: r = extract_class_name(type(raw_headers)) if r: - if r in ["requests.models.Response", "niquests.models.Response", "niquests.models.AsyncResponse"]: + if r in [ + "requests.models.Response", + "niquests.models.Response", + "niquests.models.AsyncResponse", + ]: headers = [] for header_name in raw_headers.raw.headers: for header_content in raw_headers.raw.headers.getlist(header_name): @@ -74,12 +81,10 @@ def parse_it(raw_headers: Any) -> Headers: if headers is None: raise TypeError( # pragma: no cover - "Cannot parse type {type_} as it is not supported by kiss-header.".format( - type_=type(raw_headers) - ) + f"Cannot parse type {type(raw_headers)} as it is not supported by kiss-header." ) - revised_headers: List[Tuple[str, str]] = decode_partials( + revised_headers: list[tuple[str, str]] = decode_partials( transform_possible_encoded(headers) ) @@ -90,14 +95,15 @@ def parse_it(raw_headers: Any) -> Headers: and (isinstance(raw_headers, bytes) or isinstance(raw_headers, str)) ): next_iter = raw_headers.split( - b"\n" if isinstance(raw_headers, bytes) else "\n", maxsplit=1 # type: ignore + b"\n" if isinstance(raw_headers, bytes) else "\n", # type: ignore[arg-type] + maxsplit=1, ) if len(next_iter) >= 2: return parse_it(next_iter[-1]) # Prepare Header objects - list_of_headers: List[Header] = [] + list_of_headers: list[Header] = [] for head, content in revised_headers: # We should ignore when a illegal name is considered as an header. We avoid ValueError (in __init__ of Header) @@ -105,7 +111,7 @@ def parse_it(raw_headers: Any) -> Headers: continue is_json_obj: bool = is_content_json_object(content) - entries: List[str] + entries: list[str] if is_json_obj is False: entries = header_content_split(content, ",") @@ -153,8 +159,8 @@ def explain(headers: Headers) -> CaseInsensitiveDict: def get_polymorphic( - target: Union[Headers, Header], desired_output: Type[T] -) -> Union[T, List[T], None]: + target: Headers | Header, desired_output: type[T] +) -> T | list[T] | None: """Experimental. Transform a Header or Headers object to its target `CustomHeader` subclass to access more ready-to-use methods. eg. You have a Header object named 'Set-Cookie' and you wish to extract the expiration date as a datetime. @@ -199,5 +205,5 @@ def get_polymorphic( return r # type: ignore -def dumps(headers: Headers, **kwargs: Optional[Any]) -> str: +def dumps(headers: Headers, **kwargs: Any | None) -> str: return json_dumps(encode(headers), **kwargs) # type: ignore diff --git a/kiss_headers/builder.py b/src/kiss_headers/builder.py similarity index 86% rename from kiss_headers/builder.py rename to src/kiss_headers/builder.py index eab30b7..471504e 100644 --- a/kiss_headers/builder.py +++ b/src/kiss_headers/builder.py @@ -1,9 +1,11 @@ +from __future__ import annotations + from base64 import b64decode, b64encode from datetime import datetime, timezone from email import utils from re import findall, fullmatch -from typing import Dict, List, Optional, Tuple, Union -from urllib.parse import quote as url_quote, unquote as url_unquote +from urllib.parse import quote as url_quote +from urllib.parse import unquote as url_unquote from .models import Header from .utils import ( @@ -28,13 +30,13 @@ class bellow this one. """ __squash__: bool = False # This value indicate whenever the representation of multiple entries should be squashed into one content. - __tags__: List[str] = [] + __tags__: list[str] = [] - __override__: Optional[ - str - ] = None # Create this static member in your custom header when the class name does not match the target header. + __override__: str | None = ( + None # Create this static member in your custom header when the class name does not match the target header. + ) - def __init__(self, initial_content: str = "", **kwargs: Optional[str]): + def __init__(self, initial_content: str = "", **kwargs: str | None): """ :param initial_content: Initial content of the Header if any. :param kwargs: Provided args. Any key that associate a None value are just ignored. @@ -67,7 +69,7 @@ class ContentSecurityPolicy(CustomHeader): __tags__ = ["response"] - def __init__(self, *policies: List[str]): + def __init__(self, *policies: list[str]): """ :param policies: One policy consist of a list of str like ["default-src", "'none'"]. >>> header = ContentSecurityPolicy(["default-src", "'none'"], ["img-src", "'self'", "img.example.com"]) @@ -114,16 +116,16 @@ def __init__(self, *policies: List[str]): self += " ".join(policy) # type: ignore - def get_policies_names(self) -> List[str]: + def get_policies_names(self) -> list[str]: """Fetch a list of policy name set in content.""" return [member.split(" ")[0] for member in self.attrs] - def get_policy_args(self, policy_name: str) -> Optional[List[str]]: + def get_policy_args(self, policy_name: str) -> list[str] | None: """Retrieve given arguments for a policy.""" policy_name = policy_name.lower() for member in self.attrs: - parts: List[str] = member.split(" ") + parts: list[str] = member.split(" ") if parts[0].lower() == policy_name: return parts[1:] @@ -139,13 +141,13 @@ class Accept(CustomHeader): """ __squash__: bool = True - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] def __init__( self, mime: str = "*/*", qualifier: float = 1.0, - **kwargs: Optional[str], + **kwargs: str | None, ): """ :param mime: Describe the MIME using this syntax @@ -166,7 +168,7 @@ def __init__( f"The MIME should be described using this syntax not '{mime}'" ) - args: Dict = {"q": qualifier if qualifier < 1.0 else None} + args: dict = {"q": qualifier if qualifier < 1.0 else None} args.update(kwargs) @@ -175,14 +177,14 @@ def __init__( **args, ) - def get_mime(self) -> Optional[str]: + def get_mime(self) -> str | None: """Return defined mime in current accept header.""" for el in self.attrs: if "/" in el: return el return None - def get_qualifier(self, _default: Optional[float] = 1.0) -> Optional[float]: + def get_qualifier(self, _default: float | None = 1.0) -> float | None: """Return defined qualifier for specified mime. If not set, output 1.0.""" return float(str(self["q"])) if self.has("q") else _default @@ -196,15 +198,15 @@ class ContentType(CustomHeader): to prevent this behavior, the header X-Content-Type-Options can be set to nosniff. """ - __tags__: List[str] = ["request", "response"] + __tags__: list[str] = ["request", "response"] def __init__( self, mime: str, - charset: Optional[str] = None, - format_: Optional[str] = None, - boundary: Optional[str] = None, - **kwargs: Optional[str], + charset: str | None = None, + format_: str | None = None, + boundary: str | None = None, + **kwargs: str | None, ): """ :param mime_type: The MIME type of the resource or the data. Format /. @@ -226,7 +228,7 @@ def __init__( f"The MIME should be described using this syntax not '{mime}'" ) - args: Dict = { + args: dict = { "charset": charset.upper() if charset else None, "format": format_, "boundary": boundary, @@ -236,14 +238,14 @@ def __init__( super().__init__(mime, **args) - def get_mime(self) -> Optional[str]: + def get_mime(self) -> str | None: """Return defined mime in content type.""" for el in self.attrs: if "/" in el: return el return None - def get_charset(self, _default: Optional[str] = "ISO-8859-1") -> Optional[str]: + def get_charset(self, _default: str | None = "ISO-8859-1") -> str | None: """Extract defined charset, if not present will return 'ISO-8859-1' by default.""" return str(self["charset"]) if self.has("charset") else _default @@ -256,9 +258,9 @@ class XContentTypeOptions(CustomHeader): the webmasters knew what they were doing. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] - def __init__(self, nosniff: bool = True, **kwargs: Optional[str]): + def __init__(self, nosniff: bool = True, **kwargs: str | None): """ :param nosniff: see https://fetch.spec.whatwg.org/#x-content-type-options-header :param kwargs: @@ -276,16 +278,16 @@ class ContentDisposition(CustomHeader): as part of a Web page, or as an attachment, that is downloaded and saved locally. """ - __tags__: List[str] = ["request", "response"] + __tags__: list[str] = ["request", "response"] def __init__( self, disposition: str = "inline", - name: Optional[str] = None, - filename: Optional[str] = None, - fallback_filename: Optional[str] = None, - boundary: Optional[str] = None, - **kwargs: Optional[str], + name: str | None = None, + filename: str | None = None, + fallback_filename: str | None = None, + boundary: str | None = None, + **kwargs: str | None, ): """ :param disposition: Could be either inline, form-data, attachment or empty. Choose one. Default to inline. @@ -312,12 +314,10 @@ def __init__( filename.encode("ASCII") except UnicodeEncodeError: raise ValueError( # pragma: no cover - "The filename should only contain valid ASCII characters. Not '{fb_filename}'. Use fallback_filename instead.".format( - fb_filename=fallback_filename - ) + f"The filename should only contain valid ASCII characters. Not '{fallback_filename}'. Use fallback_filename instead." ) - args: Dict = { + args: dict = { "name": name, "filename": filename, "filename*": ("UTF-8''" + url_quote(fallback_filename, encoding="utf-8")) @@ -333,7 +333,7 @@ def __init__( **args, ) - def get_disposition(self) -> Optional[str]: + def get_disposition(self) -> str | None: """Extract set disposition from Content-Disposition""" for attr in self.attrs: if attr.lower() in ["attachment", "inline", "form-data"]: @@ -341,7 +341,7 @@ def get_disposition(self) -> Optional[str]: return None - def get_filename_decoded(self) -> Optional[str]: + def get_filename_decoded(self) -> str | None: """Retrieve and decode if necessary the associated filename.""" if "filename*" in self: try: @@ -360,9 +360,9 @@ class Authorization(CustomHeader): and the WWW-Authenticate header. """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] - def __init__(self, type_: str, credentials: str, **kwargs: Optional[str]): + def __init__(self, type_: str, credentials: str, **kwargs: str | None): """ :param type_: Authentication type. A common type is "Basic". See IANA registry of Authentication schemes for others. :param credentials: Associated credentials to use. Preferably Base-64 encoded. @@ -389,7 +389,7 @@ def __init__(self, type_: str, credentials: str, **kwargs: Optional[str]): ) super().__init__( - "{type_} {credentials}".format(type_=type_, credentials=credentials), + f"{type_} {credentials}", **kwargs, ) @@ -414,7 +414,7 @@ def __init__( username: str, password: str, charset: str = "latin1", - **kwargs: Optional[str], + **kwargs: str | None, ): """ :param username: @@ -449,7 +449,7 @@ def get_credentials(self, __default_charset: str = "latin1") -> str: def get_username_password( self, __default_charset: str = "latin1" - ) -> Tuple[str, ...]: + ) -> tuple[str, ...]: """Extract username and password as a tuple from Basic Authorization.""" return tuple(self.get_credentials(__default_charset).split(":", maxsplit=1)) @@ -461,7 +461,7 @@ class ProxyAuthorization(Authorization): and the Proxy-Authenticate header. """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] def __init__(self, type_: str, credentials: str): """ @@ -477,9 +477,9 @@ class Host(CustomHeader): and (optionally) the TCP port number on which the server is listening. """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] - def __init__(self, host: str, port: Optional[int] = None, **kwargs: Optional[str]): + def __init__(self, host: str, port: int | None = None, **kwargs: str | None): """ :param host: The domain name of the server (for virtual hosting). :param port: TCP port number on which the server is listening. @@ -501,7 +501,7 @@ class Connection(CustomHeader): __tags__ = ["request", "response"] - def __init__(self, should_keep_alive: bool, **kwargs: Optional[str]): + def __init__(self, should_keep_alive: bool, **kwargs: str | None): """ :param should_keep_alive: Indicates that the client would like to keep the connection open or not. :param kwargs: @@ -514,9 +514,9 @@ class ContentLength(CustomHeader): The Content-Length entity header indicates the size of the entity-body, in bytes, sent to the recipient. """ - __tags__: List[str] = ["request", "response"] + __tags__: list[str] = ["request", "response"] - def __init__(self, length: int, **kwargs: Optional[str]): + def __init__(self, length: int, **kwargs: str | None): """ :param length: The length in decimal number of octets. """ @@ -528,9 +528,9 @@ class Date(CustomHeader): The Date general HTTP header contains the date and time at which the message was originated. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] - def __init__(self, my_date: Union[datetime, str], **kwargs: Optional[str]): + def __init__(self, my_date: datetime | str, **kwargs: str | None): """ :param my_date: Can either be a datetime that will be automatically converted or a raw string. :param kwargs: @@ -553,9 +553,9 @@ class CrossOriginResourcePolicy(CustomHeader): the browser blocks no-cors cross-origin/cross-site requests to the given resource. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] - def __init__(self, policy: str, **kwargs: Optional[str]): + def __init__(self, policy: str, **kwargs: str | None): """ :param policy: Accepted values are same-site, same-origin or cross-origin. :param kwargs: @@ -578,7 +578,7 @@ class Allow(CustomHeader): __tags__ = ["response"] __squash__ = True - def __init__(self, supported_verb: str, **kwargs: Optional[str]): + def __init__(self, supported_verb: str, **kwargs: str | None): """ :param supported_verb: Choose exactly one of "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE", "PURGE", "CONNECT" or "TRACE" HTTP verbs. :param kwargs: @@ -613,13 +613,13 @@ class Digest(CustomHeader): __tags__ = ["response"] - def __init__(self, algorithm: str, value: str, **kwargs: Optional[str]): + def __init__(self, algorithm: str, value: str, **kwargs: str | None): """ :param algorithm: Supported digest algorithms are defined in RFC 3230 and RFC 5843, and include SHA-256 and SHA-512. :param value: The result of applying the digest algorithm to the resource representation and encoding the result. :param kwargs: """ - args: Dict = {algorithm: value} + args: dict = {algorithm: value} args.update(kwargs) super().__init__("", **args) @@ -631,19 +631,19 @@ class Cookie(CustomHeader): __tags__ = ["request"] - def __init__(self, **kwargs: Optional[str]): + def __init__(self, **kwargs: str | None): """ :param kwargs: Pair of cookie name associated with a value. """ super().__init__("", **kwargs) - def get_cookies_names(self) -> List[str]: + def get_cookies_names(self) -> list[str]: """Retrieve all defined cookie names from Cookie header.""" return self.attrs def get_cookie_value( - self, cookie_name: str, __default: Optional[str] = None - ) -> Optional[str]: + self, cookie_name: str, __default: str | None = None + ) -> str | None: """Retrieve associated value with a given cookie name.""" return ( str(self[cookie_name]).replace('\\"', "") @@ -658,20 +658,20 @@ class SetCookie(CustomHeader): so the user agent can send them back to the server later. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] def __init__( self, cookie_name: str, cookie_value: str, - expires: Optional[Union[datetime, str]] = None, - max_age: Optional[int] = None, - domain: Optional[str] = None, - path: Optional[str] = None, - samesite: Optional[str] = None, + expires: datetime | str | None = None, + max_age: int | None = None, + domain: str | None = None, + path: str | None = None, + samesite: str | None = None, is_secure: bool = False, is_httponly: bool = True, - **kwargs: Optional[str], + **kwargs: str | None, ): """ :param cookie_name: Can be any US-ASCII characters, except control characters, spaces, or tabs. @@ -697,7 +697,7 @@ def __init__( "Samesite attribute can only be one of the following: Strict, Lax or None." ) - args: Dict = { + args: dict = { cookie_name: cookie_value, "expires": utils.format_datetime( expires.astimezone(timezone.utc), usegmt=True @@ -727,7 +727,7 @@ def is_secure(self) -> bool: """Determine if the cookie is TLS/SSL only.""" return "Secure" in self - def get_expire(self) -> Optional[datetime]: + def get_expire(self) -> datetime | None: """Retrieve the parsed expiration date.""" return ( utils.parsedate_to_datetime(str(self["expires"])) @@ -735,7 +735,7 @@ def get_expire(self) -> Optional[datetime]: else None ) - def get_max_age(self) -> Optional[int]: + def get_max_age(self) -> int | None: """Getting the max-age value as an integer if set.""" return int(str(self["max-age"])) if "max-age" in self else None @@ -754,14 +754,14 @@ class StrictTransportSecurity(CustomHeader): tell browsers that it should only be accessed using HTTPS, instead of using HTTP. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] def __init__( self, max_age: int, does_includesubdomains: bool = False, is_preload: bool = False, - **kwargs: Optional[str], + **kwargs: str | None, ): """ :param max_age: The time, in seconds, that the browser should remember that a site is only to be accessed using HTTPS. @@ -769,7 +769,7 @@ def __init__( :param is_preload: Preloading Strict Transport Security. Google maintains an HSTS preload service. By following the guidelines and successfully submitting your domain, browsers will never connect to your domain using an insecure connection. :param kwargs: """ - args: Dict = {"max-age": max_age} + args: dict = {"max-age": max_age} args.update(kwargs) @@ -789,7 +789,7 @@ def should_preload(self) -> bool: """Verify if Preloading Strict Transport Security should be set.""" return "preload" in self - def get_max_age(self) -> Optional[int]: + def get_max_age(self) -> int | None: """Get the time, in seconds, if set, that the browser should remember.""" return int(str(self["max-age"])) if self.has("max-age") else None @@ -801,9 +801,9 @@ class UpgradeInsecureRequests(CustomHeader): can successfully handle the upgrade-insecure-requests CSP directive. """ - __tags__: List[str] = ["request", "response"] + __tags__: list[str] = ["request", "response"] - def __init__(self, **kwargs: Optional[str]): + def __init__(self, **kwargs: str | None): super().__init__("1", **kwargs) @@ -812,13 +812,13 @@ class TransferEncoding(CustomHeader): The Transfer-Encoding header specifies the form of encoding used to safely transfer the payload body to the user. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] __squash__: bool = True def __init__( self, method: str, - **kwargs: Optional[str], + **kwargs: str | None, ): """ :param method: Either chunked, compress, deflate, gzip, identity or br. @@ -854,7 +854,7 @@ class ContentEncoding(TransferEncoding): __tags__ = ["request", "response"] - def __init__(self, method: str, **kwargs: Optional[str]): + def __init__(self, method: str, **kwargs: str | None): """ :param method: Either chunked, compress, deflate, gzip, identity or br. :param kwargs: @@ -872,19 +872,19 @@ class AcceptEncoding(TransferEncoding): __tags__ = ["request"] - def __init__(self, method: str, qualifier: float = 1.0, **kwargs: Optional[str]): + def __init__(self, method: str, qualifier: float = 1.0, **kwargs: str | None): """ :param method: Either chunked, compress, deflate, gzip, identity, br or a wildcard. :param qualifier: Any value used is placed in an order of preference expressed using relative quality value called the weight. :param kwargs: """ - args: Dict = {"q": qualifier if qualifier != 1.0 else None} + args: dict = {"q": qualifier if qualifier != 1.0 else None} args.update(kwargs) super().__init__(method, **args) - def get_qualifier(self, _default: Optional[float] = 1.0) -> Optional[float]: + def get_qualifier(self, _default: float | None = 1.0) -> float | None: """Return defined qualifier for specified encoding. If not set, output 1.0.""" return float(str(self["q"])) if self.has("q") else _default @@ -895,9 +895,9 @@ class Dnt(CustomHeader): It lets users indicate whether they would prefer privacy rather than personalized content. """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] - def __init__(self, tracking_consent: bool = False, **kwargs: Optional[str]): + def __init__(self, tracking_consent: bool = False, **kwargs: str | None): """ :param tracking_consent: The user prefers to allow tracking on the target site or not. :param kwargs: @@ -911,9 +911,9 @@ class UserAgent(CustomHeader): peers identify the application, operating system, vendor, and/or version of the requesting user agent. """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] - def __init__(self, characteristics: str, **kwargs: Optional[str]): + def __init__(self, characteristics: str, **kwargs: str | None): super().__init__(characteristics, **kwargs) @@ -923,17 +923,17 @@ class AltSvc(CustomHeader): the same resource can be reached. An alternative service is defined by a protocol/host/port combination. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] __squash__: bool = True def __init__( self, protocol_id: str, alt_authority: str, - max_age: Optional[int] = None, - versions: Optional[List[str]] = None, - do_persist: Optional[bool] = None, - **kwargs: Optional[str], + max_age: int | None = None, + versions: list[str] | None = None, + do_persist: bool | None = None, + **kwargs: str | None, ): """ :param protocol_id: The ALPN protocol identifier. Examples include h2 for HTTP/2 and h3-25 for draft 25 of the HTTP/3 protocol. @@ -943,7 +943,7 @@ def __init__( :param do_persist: Use the parameter to ensures that the entry is not deleted through network configuration changes. :param kwargs: """ - args: Dict = { + args: dict = { protocol_id: alt_authority, "ma": max_age, "persist": 1 if do_persist else None, @@ -962,15 +962,15 @@ def get_alt_authority(self) -> str: """Extract the alternative authority which consists of an optional host override, a colon, and a mandatory port number.""" return str(self[self.get_protocol_id()]) - def get_max_age(self) -> Optional[int]: + def get_max_age(self) -> int | None: """Output the number of seconds for which the alternative service is considered fresh. None if undefined.""" return int(str(self["ma"])) if "ma" in self else None - def get_versions(self) -> Optional[List[str]]: + def get_versions(self) -> list[str] | None: """May return, if available, a list of versions of the ALPN protocol identifier.""" return str(self["v"]).split(",") if "v" in self else None - def should_persist(self) -> Optional[bool]: + def should_persist(self) -> bool | None: """Verify if the entry should not be deleted through network configuration changes. None if no indication.""" return str(self["persist"]) == "1" if "persist" in self else None @@ -981,15 +981,15 @@ class Forwarded(CustomHeader): that is altered or lost when a proxy is involved in the path of the request. """ - __tags__: List[str] = ["request", "response"] + __tags__: list[str] = ["request", "response"] def __init__( self, by: str, for_: str, using_proto: str, - host: Optional[str] = None, - **kwargs: Optional[str], + host: str | None = None, + **kwargs: str | None, ): """ :param by: The interface where the request came in to the proxy server. Could be an IP address, an obfuscated identifier or "unknown". @@ -998,7 +998,7 @@ def __init__( :param using_proto: Indicates which protocol was used to make the request (typically "http" or "https"). :param kwargs: """ - args: Dict = {"by": by, "for": for_, "host": host, "proto": using_proto} + args: dict = {"by": by, "for": for_, "host": host, "proto": using_proto} args.update(kwargs) @@ -1012,9 +1012,9 @@ class LastModified(Date): to determine if a resource received or stored is the same. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] - def __init__(self, my_date: Union[datetime, str], **kwargs: Optional[str]): + def __init__(self, my_date: datetime | str, **kwargs: str | None): """ :param my_date: :param kwargs: @@ -1031,9 +1031,9 @@ class Referer(CustomHeader): Note that referer is actually a misspelling of the word "referrer". See https://en.wikipedia.org/wiki/HTTP_referer """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] - def __init__(self, url: str, **kwargs: Optional[str]): + def __init__(self, url: str, **kwargs: str | None): """ :param url: An absolute or partial address of the previous web page from which a link to the currently requested page was followed. URL fragments not included. :param kwargs: @@ -1047,10 +1047,10 @@ class ReferrerPolicy(CustomHeader): (sent via the Referer header) should be included with requests. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] __squash__ = True - def __init__(self, policy: str, **kwargs: Optional[str]): + def __init__(self, policy: str, **kwargs: str | None): """ :param policy: Either "no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url" :param kwargs: @@ -1078,9 +1078,9 @@ class RetryAfter(Date): before making a follow-up request. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] - def __init__(self, delay_or_date: Union[datetime, int], **kwargs: Optional[str]): + def __init__(self, delay_or_date: datetime | int, **kwargs: str | None): super().__init__( delay_or_date if isinstance(delay_or_date, datetime) @@ -1097,17 +1097,17 @@ class AcceptLanguage(CustomHeader): """ __squash__: bool = True - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] def __init__( - self, language: str = "*", qualifier: float = 1.0, **kwargs: Optional[str] + self, language: str = "*", qualifier: float = 1.0, **kwargs: str | None ): """ :param language: A language tag (which is sometimes referred to as a "locale identifier"). This consists of a 2-3 letter base language tag representing the language. :param qualifier: Any value placed in an order of preference expressed using a relative quality value called weight. :param kwargs: """ - args: Dict = {"q": qualifier if qualifier < 1.0 else None} + args: dict = {"q": qualifier if qualifier < 1.0 else None} args.update(kwargs) @@ -1116,7 +1116,7 @@ def __init__( **args, ) - def get_qualifier(self, _default: Optional[float] = 1.0) -> Optional[float]: + def get_qualifier(self, _default: float | None = 1.0) -> float | None: """Return defined qualifier for specified language. If not set, output 1.0.""" return float(str(self["q"])) if self.has("q") else _default @@ -1128,13 +1128,13 @@ class Etag(CustomHeader): resend a full response if the content has not changed. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] def __init__( self, etag_value: str, is_a_weak_validator: bool = False, - **kwargs: Optional[str], + **kwargs: str | None, ): """ :param etag_value: Entity tag uniquely representing the requested resource. ASCII string only. Not quoted. @@ -1157,9 +1157,9 @@ class XFrameOptions(CustomHeader): avoid clickjacking attacks, by ensuring that their content is not embedded into other sites. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] - def __init__(self, policy: str, **kwargs: Optional[str]): + def __init__(self, policy: str, **kwargs: str | None): """ :param policy: Can be either DENY or SAMEORIGIN. :param kwargs: @@ -1182,14 +1182,14 @@ class XXssProtection(CustomHeader): Content-Security-Policy that disables the use of inline JavaScript """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] def __init__( self, enable_filtering: bool = True, enable_block_rendering: bool = False, - report_uri: Optional[str] = None, - **kwargs: Optional[str], + report_uri: str | None = None, + **kwargs: str | None, ): """ :param enable_filtering: Enables XSS filtering (usually default in browsers). If a cross-site scripting attack is detected, the browser will sanitize the page (remove the unsafe parts). @@ -1201,7 +1201,7 @@ def __init__( super().__init__("0", **kwargs) return - args: Dict = { + args: dict = { "mode": "block" if enable_block_rendering else None, "report": report_uri, } @@ -1218,15 +1218,15 @@ class WwwAuthenticate(CustomHeader): Fair-Warning : This header is like none other and is harder to parse. It need a specific case. """ - __tags__: List[str] = ["response"] + __tags__: list[str] = ["response"] __squash__ = True def __init__( self, - auth_type: Optional[str] = None, + auth_type: str | None = None, challenge: str = "realm", value: str = "Secured area", - **kwargs: Optional[str], + **kwargs: str | None, ): """ >>> www_authenticate = WwwAuthenticate("Basic", "realm", "Secured area") @@ -1241,21 +1241,21 @@ def __init__( 'Basic' """ super().__init__( - f'{auth_type+" " if auth_type else ""}{challenge}="{value}"', **kwargs + f'{auth_type + " " if auth_type else ""}{challenge}="{value}"', **kwargs ) - def get_auth_type(self) -> Optional[str]: + def get_auth_type(self) -> str | None: """Retrieve given authentication method if defined.""" - parts: List[str] = header_content_split(str(self), " ") + parts: list[str] = header_content_split(str(self), " ") if len(parts) >= 1 and "=" not in parts: return parts[0] return None - def get_challenge(self) -> Tuple[str, str]: + def get_challenge(self) -> tuple[str, str]: """Output a tuple containing the challenge and the associated value. Raises :ValueError:""" - parts: List[str] = header_content_split(str(self), " ") + parts: list[str] = header_content_split(str(self), " ") for part in parts: if "=" in part: @@ -1263,7 +1263,7 @@ def get_challenge(self) -> Tuple[str, str]: return challenge, unquote(value) raise ValueError( # pragma: no cover - f"WwwAuthenticate header does not seems to contain a valid content. No challenge detected." + "WwwAuthenticate header does not seems to contain a valid content. No challenge detected." ) @@ -1276,7 +1276,7 @@ class XDnsPrefetchControl(CustomHeader): __tags__ = ["response"] - def __init__(self, enable: bool = True, **kwargs: Optional[str]): + def __init__(self, enable: bool = True, **kwargs: str | None): """ :param enable: Toggle the specified behaviour. :param kwargs: @@ -1292,7 +1292,7 @@ class Location(CustomHeader): __tags__ = ["response"] - def __init__(self, uri: str, **kwargs: Optional[str]): + def __init__(self, uri: str, **kwargs: str | None): """ :param uri: A relative (to the request URL) or absolute URL. :param kwargs: @@ -1307,9 +1307,9 @@ class From(CustomHeader): if problems occur on servers, such as if the robot is sending excessive, unwanted, or invalid requests. """ - __tags__: List[str] = ["request"] + __tags__: list[str] = ["request"] - def __init__(self, email: str, **kwargs: Optional[str]): + def __init__(self, email: str, **kwargs: str | None): """ :param email: A machine-usable email address. See RFC 5322. :param kwargs: @@ -1337,8 +1337,8 @@ def __init__( unit: str, start: int, end: int, - size: Union[str, int], - **kwargs: Optional[str], + size: str | int, + **kwargs: str | None, ): """ :param unit: The unit in which ranges is specified. This is usually bytes. @@ -1356,7 +1356,7 @@ def __init__( """ super().__init__(f"{unit} {start}-{end}/{size}", **kwargs) - def unpack(self) -> Tuple[str, str, str, str]: + def unpack(self) -> tuple[str, str, str, str]: """Provide a basic way to parse ContentRange format.""" return findall( r"^([0-9a-zA-Z*]+) ([0-9a-zA-Z*]+)-([0-9a-zA-Z*]+)/([0-9a-zA-Z*]+)$", @@ -1375,7 +1375,7 @@ def get_end(self) -> int: """Get the end of the requested range.""" return int(self.unpack()[2]) - def get_size(self) -> Union[str, int]: + def get_size(self) -> str | int: """Get the total size of the document (or '*' if unknown).""" size: str = self.unpack()[3] return int(size) if size.isdigit() else size @@ -1393,12 +1393,12 @@ class CacheControl(CustomHeader): def __init__( self, - directive: Optional[str] = None, - max_age: Optional[int] = None, - max_stale: Optional[int] = None, - min_fresh: Optional[int] = None, - s_maxage: Optional[int] = None, - **kwargs: Optional[str], + directive: str | None = None, + max_age: int | None = None, + max_stale: int | None = None, + min_fresh: int | None = None, + s_maxage: int | None = None, + **kwargs: str | None, ): """ Pass only one parameter per CacheControl instance. @@ -1420,7 +1420,7 @@ def __init__( "You should only pass one parameter to a single CacheControl instance." ) - args: Dict = { + args: dict = { "max-age": max_age, "max-stale": max_stale, "min-fresh": min_fresh, @@ -1440,9 +1440,7 @@ class Expires(Date): __tags__ = ["response"] - def __init__( - self, datetime_or_custom: Union[datetime, str], **kwargs: Optional[str] - ): + def __init__(self, datetime_or_custom: datetime | str, **kwargs: str | None): super().__init__(datetime_or_custom, **kwargs) @@ -1453,7 +1451,7 @@ class IfModifiedSince(Date): __tags__ = ["request"] - def __init__(self, dt: Union[datetime, str], **kwargs: Optional[str]): + def __init__(self, dt: datetime | str, **kwargs: str | None): """ :param dt: :param kwargs: @@ -1466,7 +1464,7 @@ class IfUnmodifiedSince(Date): The If-Unmodified-Since request HTTP header makes the request conditional """ - def __init__(self, dt: Union[datetime, str], **kwargs: Optional[str]): + def __init__(self, dt: datetime | str, **kwargs: str | None): """ :param dt: :param kwargs: @@ -1485,9 +1483,9 @@ class KeepAlive(CustomHeader): def __init__( self, - timeout: Optional[int] = None, - max_: Optional[int] = None, - **kwargs: Optional[str], + timeout: int | None = None, + max_: int | None = None, + **kwargs: str | None, ): """ :param timeout: indicating the minimum amount of time an idle connection has to be kept opened (in seconds). @@ -1499,7 +1497,7 @@ def __init__( "Can only provide one parameter per KeepAlive instance, either timeout or max, not both." ) - args: Dict = {"timeout": timeout, "max": max_} + args: dict = {"timeout": timeout, "max": max_} args.update(kwargs) @@ -1516,7 +1514,7 @@ class IfMatch(CustomHeader): __squash__ = True __tags__ = ["request"] - def __init__(self, etag_value: str, **kwargs: Optional[str]): + def __init__(self, etag_value: str, **kwargs: str | None): """ :param etag_value: Entity tags uniquely representing the requested resources. They are a string of ASCII characters placed between double quotes (like "675af34563dc-tr34"). :param kwargs: @@ -1532,7 +1530,7 @@ class IfNoneMatch(IfMatch): ETag doesn't match any of the values listed. """ - def __init__(self, etag_value: str, **kwargs: Optional[str]): + def __init__(self, etag_value: str, **kwargs: str | None): super().__init__(etag_value, **kwargs) @@ -1542,7 +1540,7 @@ class Server(CustomHeader): __tags__ = ["response"] - def __init__(self, product: str, **kwargs: Optional[str]): + def __init__(self, product: str, **kwargs: str | None): """ :param product: The name of the software or product that handled the request. Usually in a format similar to User-Agent. :param kwargs: @@ -1557,7 +1555,7 @@ class Vary(CustomHeader): __squash__ = True __tags__ = ["response"] - def __init__(self, header_name: str, **kwargs: Optional[str]): + def __init__(self, header_name: str, **kwargs: str | None): """ :param header_name: An header name to take into account when deciding whether or not a cached response can be used. :param kwargs: diff --git a/kiss_headers/models.py b/src/kiss_headers/models.py similarity index 84% rename from kiss_headers/models.py rename to src/kiss_headers/models.py index 8600b0c..80473ba 100644 --- a/kiss_headers/models.py +++ b/src/kiss_headers/models.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from copy import deepcopy from json import JSONDecodeError, dumps, loads -from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union +from typing import Iterable, Iterator from .structures import AttributeBag, CaseInsensitiveDict from .utils import ( @@ -60,7 +62,7 @@ def __init__(self, name: str, content: str): self._pretty_name: str = prettify_header_name(self._name) self._content: str = content - self._members: List[str] + self._members: list[str] if is_content_json_object(self._content): try: @@ -128,7 +130,7 @@ def unfolded_content(self) -> str: return unfold(self.content) @property - def comments(self) -> List[str]: + def comments(self) -> list[str]: """Retrieve comments in header content.""" return extract_comments(self.content) @@ -179,13 +181,11 @@ def __ge__(self, other: object) -> bool: return self.normalized_name >= other.normalized_name - def __deepcopy__(self, memodict: Dict) -> "Header": + def __deepcopy__(self, memodict: dict) -> Header: """Simply provide a deepcopy of a Header object. Pointer/Reference is free of the initial reference.""" return Header(deepcopy(self.name), deepcopy(self.content)) - def pop( - self, __index: Union[int, str] = -1 - ) -> Tuple[str, Optional[Union[str, List[str]]]]: + def pop(self, __index: int | str = -1) -> tuple[str, str | list[str] | None]: """Permit to pop an element from a Header with a given index. >>> header = Header("X", "a; b=k; h; h; z=0; y=000") >>> header.pop(1) @@ -211,9 +211,7 @@ def pop( return key, value - def insert( - self, __index: int, *__members: str, **__attributes: Optional[str] - ) -> None: + def insert(self, __index: int, *__members: str, **__attributes: str | None) -> None: """ This method allows you to properly insert attributes into a Header instance. Insert before provided index. >>> header = Header("Content-Type", "application/json; format=flowed") @@ -237,7 +235,7 @@ def insert( # We need to update our list of members self._members = header_content_split(self._content, ";") - def __iadd__(self, other: Union[str, "Header"]) -> "Header": + def __iadd__(self, other: str | Header) -> Header: """ Allow you to assign-add any string to a Header instance. The string will be a new member of your header. >>> header = Header("X-Hello-World", "") @@ -255,11 +253,7 @@ def __iadd__(self, other: Union[str, "Header"]) -> "Header": TypeError: Cannot assign-add with type to an Header. """ if not isinstance(other, str): - raise TypeError( - "Cannot assign-add with type {type_} to an Header.".format( - type_=type(other) - ) - ) + raise TypeError(f"Cannot assign-add with type {type(other)} to an Header.") self._attrs.insert(other, None) # No need to rebuild the content completely. @@ -268,7 +262,7 @@ def __iadd__(self, other: Union[str, "Header"]) -> "Header": return self - def __add__(self, other: Union[str, "Header"]) -> Union["Header", "Headers"]: + def __add__(self, other: str | Header) -> Header | Headers: """ This implementation permits to add either a string or a Header to your Header instance. When you add a string to your Header instance, it will create another instance with a new @@ -286,9 +280,7 @@ def __add__(self, other: Union[str, "Header"]) -> Union["Header", "Headers"]: """ if not isinstance(other, str) and not isinstance(other, Header): raise TypeError( - "Cannot make addition with type {type_} to an Header.".format( - type_=type(other) - ) + f"Cannot make addition with type {type(other)} to an Header." ) if isinstance(other, Header): @@ -303,22 +295,16 @@ def __add__(self, other: Union[str, "Header"]) -> Union["Header", "Headers"]: return header_ - def __isub__(self, other: str) -> "Header": + def __isub__(self, other: str) -> Header: """ This method should allow you to remove attributes or members from the header. """ if not isinstance(other, str): - raise TypeError( - "You cannot subtract {type_} to an Header.".format( - type_=str(type(other)) - ) - ) + raise TypeError(f"You cannot subtract {str(type(other))} to an Header.") if other not in self: raise ValueError( - "You cannot subtract '{element}' from '{header_name}' Header because its not there.".format( - element=other, header_name=self.pretty_name - ) + f"You cannot subtract '{other}' from '{self.pretty_name}' Header because its not there." ) self._attrs.remove(other, with_value=False) @@ -328,7 +314,7 @@ def __isub__(self, other: str) -> "Header": return self - def __sub__(self, other: str) -> "Header": + def __sub__(self, other: str) -> Header: """ This method should allow you to remove attributes or members from the header. """ @@ -388,9 +374,7 @@ def __delitem__(self, key: str) -> None: if normalize_str(key) not in normalize_list(self.valued_attrs): raise KeyError( - "'{item}' attribute is not defined or have at least one value associated within '{header}' header.".format( - item=key, header=self.name - ) + f"'{key}' attribute is not defined or have at least one value associated within '{self.name}' header." ) self._attrs.remove(key, with_value=True) @@ -411,14 +395,12 @@ def __delattr__(self, item: str) -> None: if item not in normalize_list(self.valued_attrs): raise AttributeError( - "'{item}' attribute is not defined or have at least one value associated within '{header}' header.".format( - item=item, header=self.name - ) + f"'{item}' attribute is not defined or have at least one value associated within '{self.name}' header." ) del self[item] - def __iter__(self) -> Iterator[Tuple[str, Optional[Union[str, List[str]]]]]: + def __iter__(self) -> Iterator[tuple[str, str | list[str] | None]]: """Provide a way to iter over a Header object. This will yield a Tuple of key, value. The value would be None if the key is a member without associated value.""" for i in range(0, len(self._attrs)): @@ -438,9 +420,7 @@ def __eq__(self, other: object) -> bool: return self._attrs == other._attrs return False raise NotImplementedError( - "Cannot compare type {type_} to an Header. Use str or Header.".format( - type_=type(other) - ) + f"Cannot compare type {type(other)} to an Header. Use str or Header." ) def __str__(self) -> str: @@ -453,7 +433,7 @@ def __repr__(self) -> str: """ Unambiguous representation of a single header. """ - return "{head}: {content}".format(head=self._name, content=self._content) + return f"{self._name}: {self._content}" def __bytes__(self) -> bytes: """ @@ -470,13 +450,13 @@ def __dir__(self) -> Iterable[str]: return list(super().__dir__()) + normalize_list(self._attrs.keys()) @property - def attrs(self) -> List[str]: + def attrs(self) -> list[str]: """ List of members or attributes found in provided content. This list is ordered and normalized. eg. Content-Type: application/json; charset=utf-8; format=origin Would output : ['application/json', 'charset', 'format'] """ - attrs: List[str] = [] + attrs: list[str] = [] if len(self._attrs) == 0: return attrs @@ -488,7 +468,7 @@ def attrs(self) -> List[str]: return attrs @property - def valued_attrs(self) -> List[str]: + def valued_attrs(self) -> list[str]: """ List of distinct attributes that have at least one value associated with them. This list is ordered and normalized. This property could have been written under the keys() method, but implementing it would interfere with dict() @@ -496,7 +476,7 @@ def valued_attrs(self) -> List[str]: eg. Content-Type: application/json; charset=utf-8; format=origin Would output : ['charset', 'format'] """ - attrs: List[str] = [] + attrs: list[str] = [] if len(self._attrs) == 0: return attrs @@ -515,7 +495,7 @@ def has(self, attr: str) -> bool: """ return attr in self - def get(self, attr: str) -> Optional[Union[str, List[str]]]: + def get(self, attr: str) -> str | list[str] | None: """ Retrieve the associated value of an attribute. >>> header = Header("Content-Type", "application/json; charset=UTF-8; format=flowed") @@ -549,7 +529,7 @@ def has_many(self, name: str) -> bool: return isinstance(r, list) and len(r) > 1 - def __getitem__(self, item: Union[str, int]) -> Union[str, List[str]]: + def __getitem__(self, item: str | int) -> str | list[str]: """ This method will allow you to retrieve attribute value using the bracket syntax, list-like, or dict-like. """ @@ -562,9 +542,7 @@ def __getitem__(self, item: Union[str, int]) -> Union[str, List[str]]: value = self._attrs[item] # type: ignore else: raise KeyError( - "'{item}' attribute is not defined or does not have at least one value within the '{header}' header.".format( - item=item, header=self.name - ) + f"'{item}' attribute is not defined or does not have at least one value within the '{self.name}' header." ) if OUTPUT_LOCK_TYPE and isinstance(value, str): @@ -576,7 +554,7 @@ def __getitem__(self, item: Union[str, int]) -> Union[str, List[str]]: else [unfold(unquote(v)) for v in value] # type: ignore ) - def __getattr__(self, item: str) -> Union[str, List[str]]: + def __getattr__(self, item: str) -> str | list[str]: """ All the magic happens here, this method should be invoked when trying to call (not declared) properties. For instance, calling self.charset should end up here and be replaced by self['charset']. @@ -585,9 +563,7 @@ def __getattr__(self, item: str) -> Union[str, List[str]]: if normalize_str(item) not in normalize_list(self.valued_attrs): raise AttributeError( - "'{item}' attribute is not defined or have at least one value within '{header}' header.".format( - item=item, header=self.name - ) + f"'{item}' attribute is not defined or have at least one value within '{self.name}' header." ) return self[item] @@ -615,72 +591,72 @@ class Headers: # Most common headers that you may or may not find. This should be appreciated when having auto-completion. # Lowercase only. - access_control_allow_origin: Union[Header, List[Header]] + access_control_allow_origin: Header | list[Header] - www_authenticate: Union[Header, List[Header]] - authorization: Union[Header, List[Header]] - proxy_authenticate: Union[Header, List[Header]] - proxy_authorization: Union[Header, List[Header]] + www_authenticate: Header | list[Header] + authorization: Header | list[Header] + proxy_authenticate: Header | list[Header] + proxy_authorization: Header | list[Header] - alt_svc: Union[Header, List[Header]] + alt_svc: Header | list[Header] - location: Union[Header, List[Header]] + location: Header | list[Header] - age: Union[Header, List[Header]] - cache_control: Union[Header, List[Header]] - clear_site_data: Union[Header, List[Header]] - expires: Union[Header, List[Header]] - pragma: Union[Header, List[Header]] - warning: Union[Header, List[Header]] + age: Header | list[Header] + cache_control: Header | list[Header] + clear_site_data: Header | list[Header] + expires: Header | list[Header] + pragma: Header | list[Header] + warning: Header | list[Header] - last_modified: Union[Header, List[Header]] - etag: Union[Header, List[Header]] - if_match: Union[Header, List[Header]] - if_none_match: Union[Header, List[Header]] - if_modified_since: Union[Header, List[Header]] - if_unmodified_since: Union[Header, List[Header]] - vary: Union[Header, List[Header]] - connection: Union[Header, List[Header]] - keep_alive: Union[Header, List[Header]] + last_modified: Header | list[Header] + etag: Header | list[Header] + if_match: Header | list[Header] + if_none_match: Header | list[Header] + if_modified_since: Header | list[Header] + if_unmodified_since: Header | list[Header] + vary: Header | list[Header] + connection: Header | list[Header] + keep_alive: Header | list[Header] - x_cache: Union[Header, List[Header]] - via: Union[Header, List[Header]] + x_cache: Header | list[Header] + via: Header | list[Header] - accept: Union[Header, List[Header]] - accept_charset: Union[Header, List[Header]] - accept_encoding: Union[Header, List[Header]] - accept_language: Union[Header, List[Header]] + accept: Header | list[Header] + accept_charset: Header | list[Header] + accept_encoding: Header | list[Header] + accept_language: Header | list[Header] - expect: Union[Header, List[Header]] + expect: Header | list[Header] - cookie: Union[Header, List[Header]] - set_cookie: Union[Header, List[Header]] + cookie: Header | list[Header] + set_cookie: Header | list[Header] - content_disposition: Union[Header, List[Header]] - content_type: Union[Header, List[Header]] - content_range: Union[Header, List[Header]] - content_encoding: Union[Header, List[Header]] + content_disposition: Header | list[Header] + content_type: Header | list[Header] + content_range: Header | list[Header] + content_encoding: Header | list[Header] - host: Union[Header, List[Header]] - referer: Union[Header, List[Header]] - referrer_policy: Union[Header, List[Header]] - user_agent: Union[Header, List[Header]] + host: Header | list[Header] + referer: Header | list[Header] + referrer_policy: Header | list[Header] + user_agent: Header | list[Header] - allow: Union[Header, List[Header]] - server: Union[Header, List[Header]] + allow: Header | list[Header] + server: Header | list[Header] - transfer_encoding: Union[Header, List[Header]] - date: Union[Header, List[Header]] + transfer_encoding: Header | list[Header] + date: Header | list[Header] - from_: Union[Header, List[Header]] + from_: Header | list[Header] - report_to: Union[Header, List[Header]] + report_to: Header | list[Header] - def __init__(self, *headers: Union[List[Header], Header]): + def __init__(self, *headers: list[Header] | Header): """ :param headers: Initial list of header. Can be empty. """ - self._headers: List[Header] = ( + self._headers: list[Header] = ( headers[0] if len(headers) == 1 and isinstance(headers[0], list) else list(headers) # type: ignore @@ -692,7 +668,7 @@ def has(self, header: str) -> bool: """ return header in self - def get(self, header: str) -> Optional[Union[Header, List[Header]]]: + def get(self, header: str) -> Header | list[Header] | None: """ Retrieve header from headers if exists. """ @@ -720,10 +696,9 @@ def __iter__(self) -> Iterator[Header]: """ Act like a list by yielding one element at a time. Each element is a Header object. """ - for header in self._headers: - yield header + yield from self._headers - def keys(self) -> List[str]: + def keys(self) -> list[str]: """ Return a list of distinct header name set in headers. Be aware that it won't return a typing.KeysView. @@ -746,7 +721,7 @@ def values(self) -> None: """ raise NotImplementedError - def items(self) -> List[Tuple[str, str]]: + def items(self) -> list[tuple[str, str]]: """ Provide a list witch each entry contains a tuple of header name and content. This won't return an ItemView as Headers does not inherit from Mapping. @@ -754,7 +729,7 @@ def items(self) -> List[Tuple[str, str]]: >>> headers.items() [('X-Hello-World', '1'), ('Content-Type', 'happiness=True'), ('Content-Type', 'happiness=False')] """ - items: List[Tuple[str, str]] = [] + items: list[tuple[str, str]] = [] for header in self: items.append((header.name, header.content)) @@ -779,7 +754,7 @@ def to_dict(self) -> CaseInsensitiveDict: return dict_headers - def __deepcopy__(self, memodict: Dict) -> "Headers": + def __deepcopy__(self, memodict: dict) -> Headers: """ Just provide a deepcopy of the current Headers object. Pointer/reference is free of the current instance. """ @@ -799,9 +774,7 @@ def __delitem__(self, key: str) -> None: to_be_removed = [] if key not in self: - raise KeyError( - "'{item}' header is not defined in headers.".format(item=key) - ) + raise KeyError(f"'{key}' header is not defined in headers.") for header in self: if header.normalized_name == key: @@ -824,16 +797,14 @@ def __setitem__(self, key: str, value: str) -> None: """ if not isinstance(value, str): raise TypeError( - "Cannot assign header '{key}' using type {type_} to headers.".format( - key=key, type_=type(value) - ) + f"Cannot assign header '{key}' using type {type(value)} to headers." ) if key in self: del self[key] # Permit to detect multiple entries. if normalize_str(key) != "subject": - entries: List[str] = header_content_split(value, ",") + entries: list[str] = header_content_split(value, ",") if len(entries) > 1: for entry in entries: @@ -854,9 +825,7 @@ def __delattr__(self, item: str) -> None: False """ if item not in self: - raise AttributeError( - "'{item}' header is not defined in headers.".format(item=item) - ) + raise AttributeError(f"'{item}' header is not defined in headers.") del self[item] @@ -875,9 +844,7 @@ def __eq__(self, other: object) -> bool: """ if not isinstance(other, Headers): raise NotImplementedError( - "Cannot compare type {type_} to an Header. Use str or Header.".format( - type_=type(other) - ) + f"Cannot compare type {type(other)} to an Header. Use str or Header." ) if len(other) != len(self): return False @@ -905,7 +872,7 @@ def __repr__(self) -> str: Non-ambiguous representation of a Headers instance. Using CRLF as described in rfc2616. The repr of Headers will not end with blank(s) line(s). You have to add it yourself, depending on your needs. """ - result: List[str] = [] + result: list[str] = [] for header_name in self.keys(): r = self.get(header_name) @@ -915,7 +882,7 @@ def __repr__(self) -> str: f"This should not happen. Cannot get '{header_name}' from headers when keys() said its there." ) - target_subclass: Optional[Type] = None + target_subclass: type | None = None try: target_subclass = ( @@ -946,7 +913,7 @@ def __repr__(self) -> str: return "\r\n".join(result) - def __add__(self, other: Header) -> "Headers": + def __add__(self, other: Header) -> Headers: """ Add using syntax c = a + b. The result is a newly created object. """ @@ -955,7 +922,7 @@ def __add__(self, other: Header) -> "Headers": return headers - def __sub__(self, other: Union[Header, str]) -> "Headers": + def __sub__(self, other: Header | str) -> Headers: """ Subtract using syntax c = a - b. The result is a newly created object. """ @@ -964,7 +931,7 @@ def __sub__(self, other: Union[Header, str]) -> "Headers": return headers - def __iadd__(self, other: Header) -> "Headers": + def __iadd__(self, other: Header) -> Headers: """ Inline add, using operator '+'. It is only possible to add to it another Header object. """ @@ -972,11 +939,9 @@ def __iadd__(self, other: Header) -> "Headers": self._headers.append(other) return self - raise TypeError( - 'Cannot add type "{type_}" to Headers.'.format(type_=str(type(other))) - ) + raise TypeError(f'Cannot add type "{str(type(other))}" to Headers.') - def __isub__(self, other: Union[Header, str]) -> "Headers": + def __isub__(self, other: Header | str) -> Headers: """ Inline subtract, using the operator '-'. If str is subtracted to it, would be looking for header named like provided str. @@ -1007,13 +972,9 @@ def __isub__(self, other: Union[Header, str]) -> "Headers": return self else: - raise TypeError( - 'Cannot subtract type "{type_}" to Headers.'.format( - type_=str(type(other)) - ) - ) + raise TypeError(f'Cannot subtract type "{str(type(other))}" to Headers.') - def __getitem__(self, item: Union[str, int]) -> Union[Header, List[Header]]: + def __getitem__(self, item: str | int) -> Header | list[Header]: """ Extract header using the bracket syntax, dict-like. The result is either a single Header or a list of Header. """ @@ -1023,11 +984,9 @@ def __getitem__(self, item: Union[str, int]) -> Union[Header, List[Header]]: item = normalize_str(item) if item not in self: - raise KeyError( - "'{item}' header is not defined in headers.".format(item=item) - ) + raise KeyError(f"'{item}' header is not defined in headers.") - headers: List[Header] = list() + headers: list[Header] = list() for header in self._headers: if header.normalized_name == item: @@ -1035,7 +994,7 @@ def __getitem__(self, item: Union[str, int]) -> Union[Header, List[Header]]: return headers if len(headers) > 1 or OUTPUT_LOCK_TYPE else headers.pop() - def __getattr__(self, item: str) -> Union[Header, List[Header]]: + def __getattr__(self, item: str) -> Header | list[Header]: """ Where the magic happens, every header is accessible via the property notation. The result is either a single Header or a list of Header. @@ -1049,9 +1008,7 @@ def __getattr__(self, item: str) -> Union[Header, List[Header]]: item = unpack_protected_keyword(item) if item not in self: - raise AttributeError( - "'{item}' header is not defined in headers.".format(item=item) - ) + raise AttributeError(f"'{item}' header is not defined in headers.") return self[item] @@ -1071,9 +1028,9 @@ def __bytes__(self) -> bytes: """ return repr(self).encode("utf-8", errors="surrogateescape") - def __reversed__(self) -> "Headers": + def __reversed__(self) -> Headers: """Return a new instance of Headers containing headers in reversed order.""" - list_of_headers: List[Header] = deepcopy(self._headers) + list_of_headers: list[Header] = deepcopy(self._headers) list_of_headers.reverse() return Headers(list_of_headers) @@ -1082,7 +1039,7 @@ def __bool__(self) -> bool: """Return True if Headers does contain at least one entry in it.""" return bool(self._headers) - def __contains__(self, item: Union[Header, str]) -> bool: + def __contains__(self, item: Header | str) -> bool: """ This method will allow you to test if a header, based on its string name, is present or not in headers. You could also use a Header object to verify it's presence. @@ -1105,9 +1062,7 @@ def insert(self, __index: int, __header: Header) -> None: self._headers.insert(__index, __header) - def index( - self, __value: Union[Header, str], __start: int = 0, __stop: int = -1 - ) -> int: + def index(self, __value: Header | str, __start: int = 0, __stop: int = -1) -> int: """ Search for the first appearance of an header based on its name or instance in Headers. Same method signature as list().index(). @@ -1130,7 +1085,7 @@ def index( """ value_is_header: bool = isinstance(__value, Header) - normalized_value: Optional[str] = ( + normalized_value: str | None = ( normalize_str(__value) if not value_is_header else None # type: ignore ) headers_len: int = len(self) @@ -1149,7 +1104,7 @@ def index( raise IndexError(f"Value '{__value}' is not present within Headers.") - def pop(self, __index_or_name: Union[str, int] = -1) -> Union[Header, List[Header]]: + def pop(self, __index_or_name: str | int = -1) -> Header | list[Header]: """ Pop header instance(s) from headers. By default the last one. Accept index as integer or header name. If you pass a header name, it will pop from Headers every entry named likewise. @@ -1196,7 +1151,7 @@ def pop(self, __index_or_name: Union[str, int] = -1) -> Union[Header, List[Heade f"Type {type(__index_or_name)} is not supported by pop() method on Headers." ) - def popitem(self) -> Tuple[str, str]: + def popitem(self) -> tuple[str, str]: """Pop the last header as a tuple (header name, header content).""" header: Header = self.pop() # type: ignore return header.name, header.content @@ -1218,7 +1173,7 @@ class Attributes: Store advanced info on attributes, members/adjectives, case insensitive on keys and keep attrs ordering. """ - def __init__(self, members: List[str]): + def __init__(self, members: list[str]): self._bag: AttributeBag = CaseInsensitiveDict() for member, index in zip(members, range(0, len(members))): @@ -1260,9 +1215,9 @@ def __str__(self) -> str: return content - def keys(self) -> List[str]: + def keys(self) -> list[str]: """This method return a list of attribute name that have at least one value associated to them.""" - keys: List[str] = [] + keys: list[str] = [] for index, key, value in self: if key not in keys and value is not None: @@ -1278,10 +1233,10 @@ def __eq__(self, other: object) -> bool: if len(self) != len(other): return False - list_repr_a: List[Tuple[int, str, Optional[str]]] = list(self) - list_repr_b: List[Tuple[int, str, Optional[str]]] = list(other) + list_repr_a: list[tuple[int, str, str | None]] = list(self) + list_repr_b: list[tuple[int, str, str | None]] = list(other) - list_check: List[Tuple[int, str, Optional[str]]] = [] + list_check: list[tuple[int, str, str | None]] = [] for index_a, key_a, value_a in list_repr_a: key_a = normalize_str(key_a) @@ -1298,15 +1253,13 @@ def __eq__(self, other: object) -> bool: return len(list_check) == len(list_repr_a) - def __getitem__( - self, item: Union[int, str] - ) -> Union[Tuple[str, Optional[str]], Union[str, List[str]]]: + def __getitem__(self, item: int | str) -> tuple[str, str | None] | str | list[str]: """ Extract item from an Attributes instance using its (integer) index or key string name (case insensible). """ if isinstance(item, str): - values: List[str] = [ + values: list[str] = [ value for value in self._bag[item][0] if value is not None ] return values if len(values) > 1 else values[0] @@ -1319,7 +1272,7 @@ def __getitem__( raise IndexError(f"{item} not in defined indexes.") def insert( - self, key: str, value: Optional[str] = None, index: Optional[int] = None + self, key: str, value: str | None = None, index: int | None = None ) -> None: """ Insert an attribute into the Attributes instance. If no value is provided, adding it at the end. @@ -1356,7 +1309,7 @@ def insert( self._bag[key][1].append(to_be_inserted) def remove( - self, key: str, index: Optional[int] = None, with_value: Optional[bool] = None + self, key: str, index: int | None = None, with_value: bool | None = None ) -> None: """ Remove attribute from an Attributes instance. If no index is provided, will remove every entry, member/adjective @@ -1381,7 +1334,7 @@ def remove( if key not in self._bag: return - freed_indexes: List[int] = [] + freed_indexes: list[int] = [] if index is not None: if with_value is not None: @@ -1429,7 +1382,7 @@ def remove( elif index_ > max_freed_index: self._bag[attr][1][cur] -= 1 - def __contains__(self, item: Union[str, Dict[str, Union[List[str], str]]]) -> bool: + def __contains__(self, item: str | dict[str, list[str] | str]) -> bool: """Verify if a member/attribute/value is in an Attributes instance. See examples bellow : >>> attributes = Attributes(["application/xml", "q=0.9", "q=0.1"]) >>> "q" in attributes @@ -1459,7 +1412,7 @@ def __contains__(self, item: Union[str, Dict[str, Union[List[str], str]]]) -> bo return False @property - def last_index(self) -> Optional[int]: + def last_index(self) -> int | None: """Simply output the latest index used in attributes. Index start from zero.""" if len(self._bag) == 0: return None @@ -1478,10 +1431,10 @@ def last_index(self) -> Optional[int]: def __len__(self) -> int: """The length of an Attributes instance is equal to the last index plus one. Not by keys() length.""" - last_index: Optional[int] = self.last_index + last_index: int | None = self.last_index return last_index + 1 if last_index is not None else 0 - def __iter__(self) -> Iterator[Tuple[int, str, Optional[str]]]: + def __iter__(self) -> Iterator[tuple[int, str, str | None]]: """Provide an iterator over all attributes with or without associated value. For each entry, output a tuple of index, attribute and a optional value.""" for i in range(0, len(self)): diff --git a/src/kiss_headers/py.typed b/src/kiss_headers/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/kiss_headers/serializer.py b/src/kiss_headers/serializer.py similarity index 86% rename from kiss_headers/serializer.py rename to src/kiss_headers/serializer.py index e554907..a089b19 100644 --- a/kiss_headers/serializer.py +++ b/src/kiss_headers/serializer.py @@ -1,19 +1,19 @@ -from typing import Dict, List, Optional, Union +from __future__ import annotations from .models import Header, Headers -def encode(headers: Headers) -> Dict[str, List[Dict]]: +def encode(headers: Headers) -> dict[str, list[dict]]: """ Provide an opinionated but reliable way to encode headers to dict for serialization purposes. """ - result: Dict[str, List[Dict]] = dict() + result: dict[str, list[dict]] = dict() for header in headers: if header.name not in result: result[header.name] = list() - encoded_header: Dict[str, Union[Optional[str], List[str]]] = dict() + encoded_header: dict[str, str | None | list[str]] = dict() for attribute, value in header: if attribute not in encoded_header: @@ -32,7 +32,7 @@ def encode(headers: Headers) -> Dict[str, List[Dict]]: return result -def decode(encoded_headers: Dict[str, List[Dict]]) -> Headers: +def decode(encoded_headers: dict[str, list[dict]]) -> Headers: """ Decode any previously encoded headers to a Headers object. """ diff --git a/kiss_headers/structures.py b/src/kiss_headers/structures.py similarity index 90% rename from kiss_headers/structures.py rename to src/kiss_headers/structures.py index 6213dc3..8f9062d 100644 --- a/kiss_headers/structures.py +++ b/src/kiss_headers/structures.py @@ -1,13 +1,17 @@ +from __future__ import annotations + from collections import OrderedDict from collections.abc import Mapping, MutableMapping from typing import ( Any, Iterator, List, - MutableMapping as MutableMappingType, Optional, Tuple, ) +from typing import ( + MutableMapping as MutableMappingType, +) from kiss_headers.utils import normalize_str @@ -44,7 +48,7 @@ class CaseInsensitiveDict(MutableMapping): behavior is undefined. """ - def __init__(self, data: Optional[Mapping] = None, **kwargs: Any): + def __init__(self, data: Mapping | None = None, **kwargs: Any): self._store: OrderedDict = OrderedDict() if data is None: data = {} @@ -61,13 +65,13 @@ def __getitem__(self, key: str) -> Any: def __delitem__(self, key: str) -> None: del self._store[normalize_str(key)] - def __iter__(self) -> Iterator[Tuple[str, Any]]: + def __iter__(self) -> Iterator[tuple[str, Any]]: return (casedkey for casedkey, mappedvalue in self._store.values()) def __len__(self) -> int: return len(self._store) - def lower_items(self) -> Iterator[Tuple[str, Any]]: + def lower_items(self) -> Iterator[tuple[str, Any]]: """Like iteritems(), but with all lowercase keys.""" return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items()) @@ -80,7 +84,7 @@ def __eq__(self, other: object) -> bool: return dict(self.lower_items()) == dict(other.lower_items()) # Copy is required - def copy(self) -> "CaseInsensitiveDict": + def copy(self) -> CaseInsensitiveDict: return CaseInsensitiveDict(dict(self._store.values())) def __repr__(self) -> str: diff --git a/kiss_headers/utils.py b/src/kiss_headers/utils.py similarity index 93% rename from kiss_headers/utils.py rename to src/kiss_headers/utils.py index afeebfc..0c70943 100644 --- a/kiss_headers/utils.py +++ b/src/kiss_headers/utils.py @@ -1,9 +1,11 @@ +from __future__ import annotations + from email.header import decode_header from json import dumps from re import findall, search, sub -from typing import Any, Iterable, List, Optional, Set, Tuple, Type, Union +from typing import Any, Iterable -RESERVED_KEYWORD: Set[str] = { +RESERVED_KEYWORD: set[str] = { "and_", "assert_", "in_", @@ -35,7 +37,7 @@ def normalize_str(string: str) -> str: return string.lower().replace("-", "_") -def normalize_list(strings: List[str]) -> List[str]: +def normalize_list(strings: list[str]) -> list[str]: """Normalize a list of string by applying fn normalize_str over each element.""" return list(map(normalize_str, strings)) @@ -68,7 +70,7 @@ def unpack_protected_keyword(name: str) -> str: return name -def extract_class_name(type_: Type) -> Optional[str]: +def extract_class_name(type_: type) -> str | None: """ Typically extract a class name from a Type. """ @@ -76,7 +78,7 @@ def extract_class_name(type_: Type) -> Optional[str]: return r[0] if r else None -def header_content_split(string: str, delimiter: str) -> List[str]: +def header_content_split(string: str, delimiter: str) -> list[str]: """ Take a string and split it according to the passed delimiter. It will ignore delimiter if inside between double quote, inside a value, or in parenthesis. @@ -101,7 +103,7 @@ def header_content_split(string: str, delimiter: str) -> List[str]: in_value: bool = False is_on_a_day: bool = False - result: List[str] = [""] + result: list[str] = [""] for letter, index in zip(string, range(0, len(string))): if letter == '"': @@ -150,7 +152,7 @@ def header_content_split(string: str, delimiter: str) -> List[str]: return result -def class_to_header_name(type_: Type) -> str: +def class_to_header_name(type_: type) -> str: """ Take a type and infer its header name. >>> from kiss_headers.builder import ContentType, XContentTypeOptions, BasicAuthorization @@ -172,7 +174,7 @@ def class_to_header_name(type_: Type) -> str: if class_raw_name.startswith("_"): class_raw_name = class_raw_name[1:] - header_name: str = str() + header_name: str = "" for letter in class_raw_name: if letter.isupper() and header_name != "": @@ -183,7 +185,7 @@ def class_to_header_name(type_: Type) -> str: return header_name -def header_name_to_class(name: str, root_type: Type) -> Type: +def header_name_to_class(name: str, root_type: type) -> type: """ The opposite of class_to_header_name function. Will raise TypeError if no corresponding entry is found. Do it recursively from the root type. @@ -216,9 +218,7 @@ def header_name_to_class(name: str, root_type: Type) -> Type: except TypeError: continue - raise TypeError( - "Cannot find a class matching header named '{name}'.".format(name=name) - ) + raise TypeError(f"Cannot find a class matching header named '{name}'.") def prettify_header_name(name: str) -> str: @@ -236,17 +236,17 @@ def prettify_header_name(name: str) -> str: return "-".join([el.capitalize() for el in name.replace("_", "-").split("-")]) -def decode_partials(items: Iterable[Tuple[str, Any]]) -> List[Tuple[str, str]]: +def decode_partials(items: Iterable[tuple[str, Any]]) -> list[tuple[str, str]]: """ This function takes a list of tuples, representing headers by key, value. Where value is bytes or string containing (RFC 2047 encoded) partials fragments like the following : >>> decode_partials([("Subject", "=?iso-8859-1?q?p=F6stal?=")]) [('Subject', 'pöstal')] """ - revised_items: List[Tuple[str, str]] = list() + revised_items: list[tuple[str, str]] = list() for head, content in items: - revised_content: str = str() + revised_content: str = "" for partial, partial_encoding in decode_header(content): if isinstance(partial, str): @@ -322,7 +322,7 @@ def header_strip(content: str, elem: str) -> str: >>> header_strip("text/html; charset=UTF-8; format=flowed", "charset=UTF-8") 'text/html; format=flowed' """ - next_semi_colon_index: Optional[int] = None + next_semi_colon_index: int | None = None try: elem_index: int = content.index(elem) @@ -390,7 +390,7 @@ def is_legal_header_name(name: str) -> bool: ) -def extract_comments(content: str) -> List[str]: +def extract_comments(content: str) -> list[str]: """ Extract parts of content that are considered as comments. Between parenthesis. >>> extract_comments("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0 (hello) llll (abc)") @@ -408,7 +408,7 @@ def unfold(content: str) -> str: return sub(r"\r\n[ ]+", " ", content) -def extract_encoded_headers(payload: bytes) -> Tuple[str, bytes]: +def extract_encoded_headers(payload: bytes) -> tuple[str, bytes]: """This function's purpose is to extract lines that can be decoded using the UTF-8 decoder. >>> extract_encoded_headers("Host: developer.mozilla.org\\r\\nX-Hello-World: 死の漢字\\r\\n\\r\\n".encode("utf-8")) ('Host: developer.mozilla.org\\r\\nX-Hello-World: 死の漢字\\r\\n', b'') @@ -416,7 +416,7 @@ def extract_encoded_headers(payload: bytes) -> Tuple[str, bytes]: ('Host: developer.mozilla.org\\r\\nX-Hello-World: 死の漢字\\r\\n', b'That IS totally random.') """ result: str = "" - lines: List[bytes] = payload.splitlines() + lines: list[bytes] = payload.splitlines() index: int = 0 for line, index in zip(lines, range(0, len(lines))): @@ -465,8 +465,8 @@ def is_content_json_object(content: str) -> bool: def transform_possible_encoded( - headers: Iterable[Tuple[Union[str, bytes], Union[str, bytes]]] -) -> Iterable[Tuple[str, str]]: + headers: Iterable[tuple[str | bytes, str | bytes]], +) -> Iterable[tuple[str, str]]: decoded = [] for k, v in headers: diff --git a/kiss_headers/version.py b/src/kiss_headers/version.py similarity index 68% rename from kiss_headers/version.py rename to src/kiss_headers/version.py index d162033..0cf76f1 100644 --- a/kiss_headers/version.py +++ b/src/kiss_headers/version.py @@ -2,5 +2,7 @@ Expose version """ +from __future__ import annotations + __version__ = "2.5.0" VERSION = __version__.split(".") diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 3d214ef..9211b0b 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import Attributes diff --git a/tests/test_builder.py b/tests/test_builder.py index 4f33035..411f525 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from datetime import datetime, timezone from email import utils @@ -19,7 +21,7 @@ class MyBuilderTestCase(unittest.TestCase): def test_custom_header_expect(self): with self.assertRaises(NotImplementedError): - k = CustomHeader("Should absolutely not work !") + k = CustomHeader("Should absolutely not work !") # noqa def test_content_type(self): self.assertEqual( @@ -32,9 +34,7 @@ def test_set_cookie(self): self.assertEqual( repr(SetCookie("MACHINE_IDENTIFIANT", "ABCDEFGHI", expires=dt)), - 'Set-Cookie: MACHINE_IDENTIFIANT="ABCDEFGHI"; expires="{dt}"; HttpOnly'.format( - dt=utils.format_datetime(dt.astimezone(timezone.utc), usegmt=True) - ), + f'Set-Cookie: MACHINE_IDENTIFIANT="ABCDEFGHI"; expires="{utils.format_datetime(dt.astimezone(timezone.utc), usegmt=True)}"; HttpOnly', ) def test_content_length(self): diff --git a/tests/test_builder_create.py b/tests/test_builder_create.py index 601efde..139c8a4 100644 --- a/tests/test_builder_create.py +++ b/tests/test_builder_create.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import ( diff --git a/tests/test_case_insensible_dict.py b/tests/test_case_insensible_dict.py index 5f5a622..978bfba 100644 --- a/tests/test_case_insensible_dict.py +++ b/tests/test_case_insensible_dict.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers.structures import CaseInsensitiveDict diff --git a/tests/test_explain.py b/tests/test_explain.py index 03405b7..e523ab9 100644 --- a/tests/test_explain.py +++ b/tests/test_explain.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import ( diff --git a/tests/test_from_unknown_mapping.py b/tests/test_from_unknown_mapping.py index 4271225..8829277 100644 --- a/tests/test_from_unknown_mapping.py +++ b/tests/test_from_unknown_mapping.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import parse_it diff --git a/tests/test_header_operation.py b/tests/test_header_operation.py index f57cc1c..3adebea 100644 --- a/tests/test_header_operation.py +++ b/tests/test_header_operation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import Header diff --git a/tests/test_header_order.py b/tests/test_header_order.py index 0655ba3..89b52f3 100644 --- a/tests/test_header_order.py +++ b/tests/test_header_order.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import Header diff --git a/tests/test_headers.py b/tests/test_headers.py index 43f9363..982917d 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import Header @@ -20,7 +22,7 @@ def test_invalid_eq(self): ) with self.assertRaises(NotImplementedError): - k = header == 1 + k = header == 1 # noqa def test_simple_eq(self): self.assertEqual( diff --git a/tests/test_headers_from_string.py b/tests/test_headers_from_string.py index d7778b7..4f976f1 100644 --- a/tests/test_headers_from_string.py +++ b/tests/test_headers_from_string.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import Header, Headers, lock_output_type, parse_it @@ -20,9 +22,7 @@ status: 200 strict-transport-security: max-age=31536000 x-frame-options: SAMEORIGIN -x-xss-protection: 0""".replace( - "\n", "\r\n" -) +x-xss-protection: 0""".replace("\n", "\r\n") RAW_HEADERS_MOZILLA = """GET /home.html HTTP/1.1 Host: developer.mozilla.org @@ -35,13 +35,11 @@ Upgrade-Insecure-Requests: 1 If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a" -Cache-Control: max-age=0""".replace( - "\n", "\r\n" -) +Cache-Control: max-age=0""".replace("\n", "\r\n") RAW_HEADERS_WITH_CONNECT = """HTTP/1.1 200 Connection established -HTTP/2 200 +HTTP/2 200 date: Tue, 28 Sep 2021 13:45:34 GMT content-type: application/epub+zip content-length: 3706401 @@ -68,9 +66,7 @@ accept-ranges: bytes expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" server: cloudflare -cf-ray: 695d69b549330686-LHR""".replace( - "\n", "\r\n" -) +cf-ray: 695d69b549330686-LHR""".replace("\n", "\r\n") class MyKissHeadersFromStringTest(unittest.TestCase): diff --git a/tests/test_headers_operation.py b/tests/test_headers_operation.py index ab71330..398ec11 100644 --- a/tests/test_headers_operation.py +++ b/tests/test_headers_operation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import Header, parse_it diff --git a/tests/test_headers_reserved_keyword.py b/tests/test_headers_reserved_keyword.py index 9e5cf29..bad978f 100644 --- a/tests/test_headers_reserved_keyword.py +++ b/tests/test_headers_reserved_keyword.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from kiss_headers import parse_it diff --git a/tests/test_polymorphic.py b/tests/test_polymorphic.py index acbcd26..e4e97f2 100644 --- a/tests/test_polymorphic.py +++ b/tests/test_polymorphic.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import unittest -from kiss_headers import Allow, ContentType, Header, get_polymorphic, parse_it +from kiss_headers import Allow, ContentType, get_polymorphic, parse_it class MyPolymorphicTestCase(unittest.TestCase): diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 013ab34..fabad98 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from requests import Response, get @@ -23,9 +25,7 @@ thanos: gem=power; gem=mind; gem=soul; gem=space; gem=time; gems; gem the-one-ring: One ring to rule them all, one ring to find them, One ring to bring them all and in the darkness bind them x-frame-options: SAMEORIGIN -x-xss-protection: 0""".replace( - "\n", "\r\n" -) +x-xss-protection: 0""".replace("\n", "\r\n") class SerializerTest(unittest.TestCase): diff --git a/tests/test_with_http_request.py b/tests/test_with_http_request.py index 1b3ab66..f90869d 100644 --- a/tests/test_with_http_request.py +++ b/tests/test_with_http_request.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import unittest -from typing import Optional from requests import Response, get @@ -7,14 +8,14 @@ class MyHttpTestKissHeaders(unittest.TestCase): - HTTPBIN_GET: Optional[Response] = None + HTTPBIN_GET: Response | None = None def setUp(self) -> None: MyHttpTestKissHeaders.HTTPBIN_GET = get("https://httpbin.org/get") def test_httpbin_raw_headers(self): headers = parse_it( - """Host: developer.mozilla.org + b"""Host: developer.mozilla.org User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 @@ -24,9 +25,7 @@ def test_httpbin_raw_headers(self): Upgrade-Insecure-Requests: 1 If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a" -Cache-Control: max-age=0""".encode( - "utf-8" - ) +Cache-Control: max-age=0""" ) self.assertEqual(17, len(headers))