feat: page validator for both section 9.1 checks
This commit is contained in:
parent
99dc9f7a88
commit
b3c436bf1a
13 changed files with 2703 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
build/
|
||||||
|
result
|
||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
directory.html
|
||||||
99
flake.lock
generated
Normal file
99
flake.lock
generated
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791594108,
|
||||||
|
"narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "6510408d147d8c5e6d76043e899e9251756f6a75",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pyproject-build-systems": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"pyproject-nix": [
|
||||||
|
"pyproject-nix"
|
||||||
|
],
|
||||||
|
"uv2nix": [
|
||||||
|
"uv2nix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791170410,
|
||||||
|
"narHash": "sha256-pyru9MpuXRiyq0Bd4q0nHpL4T6HCuZT4B+alcgAYE4A=",
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "build-system-pkgs",
|
||||||
|
"rev": "953d35d03a51dbecaf3feb3fb679eb63e90ebeb4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "build-system-pkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pyproject-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791657700,
|
||||||
|
"narHash": "sha256-vcCAvhtA7hG+/gOge29hYHy7I/dLkIsr5nLx4sFEQic=",
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "pyproject.nix",
|
||||||
|
"rev": "ddc400a6883b042646d0940e65971bb7f7e96107",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "pyproject.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"pyproject-build-systems": "pyproject-build-systems",
|
||||||
|
"pyproject-nix": "pyproject-nix",
|
||||||
|
"uv2nix": "uv2nix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uv2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"pyproject-nix": [
|
||||||
|
"pyproject-nix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791656763,
|
||||||
|
"narHash": "sha256-2mFllBdFGB4zNogzx02b8O5JObd7LeAGsndk+ZB5XvI=",
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "uv2nix",
|
||||||
|
"rev": "ddca81d752e29c063eaa914e040c3ec56ed39558",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "uv2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
129
flake.nix
Normal file
129
flake.nix
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
{
|
||||||
|
description = "Mews Profile: page validator, site directory and the mews.page site";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
pyproject-nix = {
|
||||||
|
url = "github:pyproject-nix/pyproject.nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
uv2nix = {
|
||||||
|
url = "github:pyproject-nix/uv2nix";
|
||||||
|
inputs.pyproject-nix.follows = "pyproject-nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
pyproject-build-systems = {
|
||||||
|
url = "github:pyproject-nix/build-system-pkgs";
|
||||||
|
inputs.pyproject-nix.follows = "pyproject-nix";
|
||||||
|
inputs.uv2nix.follows = "uv2nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
nixpkgs,
|
||||||
|
pyproject-nix,
|
||||||
|
uv2nix,
|
||||||
|
pyproject-build-systems,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
inherit (nixpkgs) lib;
|
||||||
|
forAllSystems = lib.genAttrs lib.systems.flakeExposed;
|
||||||
|
|
||||||
|
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
|
||||||
|
|
||||||
|
# Wheels, not sources: the service is built on the machine it runs on,
|
||||||
|
# which has one CPU, and lxml from source there takes minutes.
|
||||||
|
overlay = workspace.mkPyprojectOverlay {
|
||||||
|
sourcePreference = "wheel";
|
||||||
|
};
|
||||||
|
|
||||||
|
editableOverlay = workspace.mkEditablePyprojectOverlay {
|
||||||
|
root = "$REPO_ROOT";
|
||||||
|
};
|
||||||
|
|
||||||
|
pythonSets = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
(pkgs.callPackage pyproject-nix.build.packages {
|
||||||
|
python = pkgs.python312;
|
||||||
|
}).overrideScope
|
||||||
|
(
|
||||||
|
lib.composeManyExtensions [
|
||||||
|
pyproject-build-systems.overlays.wheel
|
||||||
|
overlay
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
specDescription = "The Mews Profile specification, version 0.1.";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
pythonSet = pythonSets.${system}.overrideScope editableOverlay;
|
||||||
|
virtualenv = pythonSet.mkVirtualEnv "mews-dev-env" workspace.deps.all;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
virtualenv
|
||||||
|
pkgs.uv
|
||||||
|
# md2mews.py is a standalone script with its own dependency
|
||||||
|
# header, so it is not part of the workspace environment.
|
||||||
|
(pkgs.python3.withPackages (ps: [ ps.mistune ]))
|
||||||
|
];
|
||||||
|
env = {
|
||||||
|
UV_NO_SYNC = "1";
|
||||||
|
UV_PYTHON = pythonSet.python.interpreter;
|
||||||
|
UV_PYTHON_DOWNLOADS = "never";
|
||||||
|
};
|
||||||
|
shellHook = ''
|
||||||
|
unset PYTHONPATH
|
||||||
|
export REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
app = pythonSets.${system}.mkVirtualEnv "mews-env" workspace.deps.default;
|
||||||
|
converter = pkgs.python3.withPackages (ps: [ ps.mistune ]);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = app;
|
||||||
|
|
||||||
|
# The published site. Every page it assembles is validated here, so a
|
||||||
|
# page that does not follow the spec cannot reach the server.
|
||||||
|
site = pkgs.runCommand "mews-site" { } ''
|
||||||
|
mkdir -p $out/spec $out/dtd
|
||||||
|
cp ${./site}/index.html ${./site}/about.html ${./site}/check.html $out/
|
||||||
|
cp ${./mews-0.1.css} $out/mews-0.1.css
|
||||||
|
cp ${./mews-0.1.css} $out/spec/mews-0.1.css
|
||||||
|
cp ${./dtd/mews-0.1.dtd} $out/dtd/mews-0.1.dtd
|
||||||
|
cp ${./dtd/xhtml-mobile12.dtd} $out/dtd/xhtml-mobile12.dtd
|
||||||
|
chmod -R u+w $out
|
||||||
|
|
||||||
|
${converter}/bin/python ${./md2mews.py} ${./doc/SPEC.md} \
|
||||||
|
-o $out/spec/0.1.html --lang en --variant mews-cool \
|
||||||
|
--description ${lib.escapeShellArg specDescription}
|
||||||
|
|
||||||
|
${app}/bin/mewslint --quiet \
|
||||||
|
$out/index.html $out/about.html $out/check.html $out/spec/0.1.html
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
6
mews/__init__.py
Normal file
6
mews/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Page validator and site directory for the Mews Profile.
|
||||||
|
|
||||||
|
See doc/SPEC.md for the format this package checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
VERSION = "0.1"
|
||||||
136
mews/css.py
Normal file
136
mews/css.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Comparing a site's stylesheet against the default one.
|
||||||
|
|
||||||
|
SPEC.md 5.2 lets a site host its own copy of the default stylesheet and change
|
||||||
|
nothing in it, except to add @font-face rules that load fonts from the site
|
||||||
|
itself. Checking that means comparing text, since a site may legitimately
|
||||||
|
differ in line endings and trailing whitespace after a copy-paste.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import difflib
|
||||||
|
from functools import cache
|
||||||
|
from importlib import resources
|
||||||
|
import re
|
||||||
|
|
||||||
|
FONT_FACE = re.compile(r"@font-face\b", re.IGNORECASE)
|
||||||
|
URL_IN_SRC = re.compile(r"url\(\s*['\"]?([^'\")]+)", re.IGNORECASE)
|
||||||
|
IMPORT_RULE = re.compile(r"@import\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Comparison:
|
||||||
|
"""What a candidate stylesheet differs from the default by."""
|
||||||
|
|
||||||
|
equal: bool
|
||||||
|
diff_line: int | None = None
|
||||||
|
diff: str = ""
|
||||||
|
font_faces: list[str] = field(default_factory=list)
|
||||||
|
has_import: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def font_urls(self) -> list[str]:
|
||||||
|
"""Every url() the added @font-face rules load."""
|
||||||
|
return [
|
||||||
|
match.group(1).strip()
|
||||||
|
for block in self.font_faces
|
||||||
|
for match in URL_IN_SRC.finditer(block)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def canonical() -> str:
|
||||||
|
"""Return the normalised default stylesheet shipped with this package."""
|
||||||
|
data = resources.files("mews.data").joinpath("mews-0.1.css").read_text("utf-8")
|
||||||
|
return normalise(data)
|
||||||
|
|
||||||
|
|
||||||
|
def normalise(text: str) -> str:
|
||||||
|
"""Strip the differences a copy may pick up without changing any rule."""
|
||||||
|
text = text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
lines = [line.rstrip() for line in text.split("\n")]
|
||||||
|
out: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if line or (out and out[-1]):
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_font_faces(text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Remove top-level @font-face blocks, returning the rest and the blocks.
|
||||||
|
|
||||||
|
Only brace depth zero counts, so an @font-face inside a media query is left
|
||||||
|
in place and shows up as a difference.
|
||||||
|
"""
|
||||||
|
blocks: list[str] = []
|
||||||
|
out: list[str] = []
|
||||||
|
index = 0
|
||||||
|
depth = 0
|
||||||
|
while index < len(text):
|
||||||
|
if depth == 0 and FONT_FACE.match(text, index):
|
||||||
|
end = _block_end(text, index)
|
||||||
|
if end is None:
|
||||||
|
break
|
||||||
|
blocks.append(text[index:end])
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
character = text[index]
|
||||||
|
if character == "{":
|
||||||
|
depth += 1
|
||||||
|
elif character == "}":
|
||||||
|
depth = max(0, depth - 1)
|
||||||
|
out.append(character)
|
||||||
|
index += 1
|
||||||
|
out.append(text[index:])
|
||||||
|
return "".join(out), blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _block_end(text: str, start: int) -> int | None:
|
||||||
|
"""Index just past the brace-balanced block beginning at start."""
|
||||||
|
opened = text.find("{", start)
|
||||||
|
if opened == -1:
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
for index in range(opened, len(text)):
|
||||||
|
if text[index] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[index] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return index + 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare(candidate: str) -> Comparison:
|
||||||
|
"""Compare a stylesheet against the default, allowing added @font-face rules."""
|
||||||
|
expected = canonical()
|
||||||
|
if normalise(candidate) == expected:
|
||||||
|
return Comparison(equal=True)
|
||||||
|
|
||||||
|
remainder, blocks = strip_font_faces(candidate)
|
||||||
|
found = normalise(remainder)
|
||||||
|
has_import = bool(IMPORT_RULE.search(candidate))
|
||||||
|
if found == expected:
|
||||||
|
return Comparison(equal=True, font_faces=blocks, has_import=has_import)
|
||||||
|
|
||||||
|
diff = list(
|
||||||
|
difflib.unified_diff(
|
||||||
|
expected.split("\n"), found.split("\n"), "mews-0.1.css", "your copy", n=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
line = next(
|
||||||
|
(
|
||||||
|
index + 1
|
||||||
|
for index, (left, right) in enumerate(
|
||||||
|
zip(expected.split("\n"), found.split("\n"), strict=False)
|
||||||
|
)
|
||||||
|
if left != right
|
||||||
|
),
|
||||||
|
min(len(expected.split("\n")), len(found.split("\n"))) + 1,
|
||||||
|
)
|
||||||
|
return Comparison(
|
||||||
|
equal=False,
|
||||||
|
diff_line=line,
|
||||||
|
diff="\n".join(diff[:8]),
|
||||||
|
font_faces=blocks,
|
||||||
|
has_import=has_import,
|
||||||
|
)
|
||||||
7
mews/data/README.md
Normal file
7
mews/data/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Packaged copies
|
||||||
|
|
||||||
|
The canonical files live at the repository root (`mews-0.1.css`) and in `dtd/`
|
||||||
|
(`mews-0.1.dtd`), where SPEC.md and `dtd/check_dtd.py` reference them. These
|
||||||
|
copies exist so the validator still finds them when it is installed as a wheel,
|
||||||
|
with nothing but the package on disk. `tests/test_data.py` fails if a copy and
|
||||||
|
its original drift apart.
|
||||||
106
mews/data/mews-0.1.css
Normal file
106
mews/data/mews-0.1.css
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/* Mews Profile default stylesheet 0.1 */
|
||||||
|
|
||||||
|
/* Base: light theme */
|
||||||
|
body {
|
||||||
|
background-color: #faf8f3;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-family: "Atkinson Hyperlegible", Verdana, Tahoma, system-ui, sans-serif;
|
||||||
|
font-size: 106%;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1em;
|
||||||
|
max-width: 40em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.6em 0 0.5em 0;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6em; margin-top: 0.5em; }
|
||||||
|
h2 { font-size: 1.3em; }
|
||||||
|
h3 { font-size: 1.1em; }
|
||||||
|
h4, h5, h6 { font-size: 1em; }
|
||||||
|
|
||||||
|
p, ul, ol, dl, blockquote, pre, table, address {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
}
|
||||||
|
ul, ol { padding-left: 1.5em; }
|
||||||
|
li { margin-bottom: 0.25em; }
|
||||||
|
dt { font-weight: bold; }
|
||||||
|
dd { margin: 0 0 0.5em 1.5em; }
|
||||||
|
|
||||||
|
a { color: #1a4f9c; text-decoration: underline; }
|
||||||
|
a:visited { color: #5b2a86; }
|
||||||
|
a:focus, a:active { outline: 2px solid #1a4f9c; }
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #c9c4b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, code, kbd, samp {
|
||||||
|
font-family: "Source Code Pro", Consolas, Menlo, "DejaVu Sans Mono", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background-color: #efece4;
|
||||||
|
padding: 0.75em;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #c9c4b8;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #c9c4b8;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
caption { font-weight: bold; text-align: left; }
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea { font-size: 1em; font-family: inherit; }
|
||||||
|
|
||||||
|
/* Body variants: light */
|
||||||
|
body.mews-warm { background-color: #fbf4e8; color: #2a2420; }
|
||||||
|
body.mews-warm a { color: #8a3d14; }
|
||||||
|
body.mews-cool { background-color: #f3f6fa; color: #1c2430; }
|
||||||
|
body.mews-cool a { color: #1a4f9c; }
|
||||||
|
body.mews-green { background-color: #f2f6ef; color: #1f2a1f; }
|
||||||
|
body.mews-green a { color: #24502a; }
|
||||||
|
body.mews-mono { background-color: #e8ebe1; color: #1e2219; }
|
||||||
|
body.mews-mono a { color: #1e2219; }
|
||||||
|
|
||||||
|
/* Dark theme: follows the system setting */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background-color: #18191b; color: #e3e1dc; }
|
||||||
|
a { color: #8fb8f5; }
|
||||||
|
a:visited { color: #c9a3f2; }
|
||||||
|
a:focus, a:active { outline-color: #8fb8f5; }
|
||||||
|
blockquote, hr, th, td { border-color: #45464a; }
|
||||||
|
pre { background-color: #222326; }
|
||||||
|
|
||||||
|
body.mews-warm { background-color: #1d1916; color: #e8dfd3; }
|
||||||
|
body.mews-warm a { color: #f0b48a; }
|
||||||
|
body.mews-cool { background-color: #161a20; color: #dde4ee; }
|
||||||
|
body.mews-cool a { color: #8fb8f5; }
|
||||||
|
body.mews-green { background-color: #161b16; color: #dbe6d8; }
|
||||||
|
body.mews-green a { color: #9fd39a; }
|
||||||
|
body.mews-mono { background-color: #1b1d18; color: #c8d0b8; }
|
||||||
|
body.mews-mono a { color: #c8d0b8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
html { color-scheme: light dark; }
|
||||||
396
mews/data/mews-0.1.dtd
Normal file
396
mews/data/mews-0.1.dtd
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<!-- Mews Profile 0.1 DTD
|
||||||
|
Published at mews.page/dtd/mews-0.1.dtd. Browsers and clients must not
|
||||||
|
fetch this file.
|
||||||
|
|
||||||
|
XHTML Mobile Profile 1.2 restricted as in the Mews Profile
|
||||||
|
Specification 0.1 section 4, extended with lang and dir (section 3).
|
||||||
|
Maintained by hand and checked against the XHTML Mobile 1.2 driver,
|
||||||
|
xhtml-mobile12.dtd (OMA-SUP-DTD_xhtml_mobile12-V1_2-20080331-A), by
|
||||||
|
check_dtd.py. It cannot be generated by stripping the driver: the
|
||||||
|
driver declares no elements itself, attribute lists only ever add,
|
||||||
|
and the rules below do not exist in XHTML-MP to be kept.
|
||||||
|
|
||||||
|
This file is standalone: it declares every element and attribute
|
||||||
|
itself, so validation never depends on the Open Mobile Alliance's or
|
||||||
|
W3C's servers. Pages keep the XHTML-MP doctype (section 3.1); only
|
||||||
|
validators use this file (section 9.1).
|
||||||
|
|
||||||
|
Encoded here: the permitted elements and attributes (4.1), the head
|
||||||
|
rules (3.3), the body variant classes (5.3) and the ban on nested
|
||||||
|
tables (4.2). 51 elements are kept from XHTML Mobile 1.2. Dropped:
|
||||||
|
acronym, base, big, button, div, legend, noscript, object, param,
|
||||||
|
script, small, span, style, sub, sup, tt. Event attributes and the
|
||||||
|
style attribute are omitted entirely.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Datatypes (as in XHTML Modularization) -->
|
||||||
|
<!ENTITY % Text.datatype "CDATA" >
|
||||||
|
<!ENTITY % URI.datatype "CDATA" >
|
||||||
|
<!ENTITY % Number.datatype "CDATA" >
|
||||||
|
<!ENTITY % LanguageCode.datatype "NMTOKEN" >
|
||||||
|
<!ENTITY % Character.datatype "CDATA" >
|
||||||
|
|
||||||
|
<!-- Common attributes. id, title, xml:lang, lang and dir are permitted
|
||||||
|
on every element in the body (SPEC.md 4.1); lang and dir are the Mews
|
||||||
|
extensions over XHTML-MP (SPEC.md 3). -->
|
||||||
|
<!ENTITY % id.attrib "id ID #IMPLIED" >
|
||||||
|
<!ENTITY % title.attrib "title CDATA #IMPLIED" >
|
||||||
|
<!ENTITY % i18n.attrib
|
||||||
|
"xml:lang %LanguageCode.datatype; #IMPLIED
|
||||||
|
lang %LanguageCode.datatype; #IMPLIED
|
||||||
|
dir (ltr | rtl) #IMPLIED" >
|
||||||
|
<!ENTITY % Common.attrib "%id.attrib; %title.attrib; %i18n.attrib;" >
|
||||||
|
|
||||||
|
<!-- Inline classes -->
|
||||||
|
<!ENTITY % InlStruct.class "br" >
|
||||||
|
<!ENTITY % InlPhras.class "| em | strong | code | kbd | samp | var | q | cite | abbr | dfn" >
|
||||||
|
<!ENTITY % InlPres.class "| b | i" >
|
||||||
|
<!ENTITY % Anchor.class "| a" >
|
||||||
|
<!ENTITY % InlSpecial.class "| img" >
|
||||||
|
<!ENTITY % InlFormNoLabel.class "| input | select | textarea" >
|
||||||
|
<!ENTITY % InlForm.class "%InlFormNoLabel.class; | label" >
|
||||||
|
<!ENTITY % Inline.extra "" >
|
||||||
|
<!ENTITY % Inline.class "%InlStruct.class;
|
||||||
|
%InlPhras.class;
|
||||||
|
%InlPres.class;
|
||||||
|
%Anchor.class;
|
||||||
|
%InlSpecial.class;
|
||||||
|
%InlForm.class;
|
||||||
|
%Inline.extra;" >
|
||||||
|
<!ENTITY % InlNoAnchor.class "%InlStruct.class;
|
||||||
|
%InlPhras.class;
|
||||||
|
%InlPres.class;
|
||||||
|
%InlSpecial.class;
|
||||||
|
%InlForm.class;
|
||||||
|
%Inline.extra;" >
|
||||||
|
<!ENTITY % InlNoLabel.class "%InlStruct.class;
|
||||||
|
%InlPhras.class;
|
||||||
|
%InlPres.class;
|
||||||
|
%Anchor.class;
|
||||||
|
%InlSpecial.class;
|
||||||
|
%InlFormNoLabel.class;
|
||||||
|
%Inline.extra;" >
|
||||||
|
|
||||||
|
<!-- Block classes -->
|
||||||
|
<!ENTITY % Heading.class "h1 | h2 | h3 | h4 | h5 | h6" >
|
||||||
|
<!ENTITY % List.class "ul | ol | dl" >
|
||||||
|
<!ENTITY % BlkStruct.class "p" >
|
||||||
|
<!ENTITY % BlkPhras.class "| pre | blockquote | address" >
|
||||||
|
<!ENTITY % BlkPres.class "| hr" >
|
||||||
|
<!ENTITY % Table.class "| table" >
|
||||||
|
<!ENTITY % Form.class "| form" >
|
||||||
|
<!ENTITY % Fieldset.class "| fieldset" >
|
||||||
|
<!ENTITY % Block.extra "" >
|
||||||
|
<!ENTITY % BlkNoForm.class "%Heading.class;
|
||||||
|
| %List.class;
|
||||||
|
| %BlkStruct.class;
|
||||||
|
%BlkPhras.class;
|
||||||
|
%BlkPres.class;
|
||||||
|
%Fieldset.class;
|
||||||
|
%Block.extra;" >
|
||||||
|
<!ENTITY % BlkNoTable.class "%BlkNoForm.class;
|
||||||
|
%Form.class;
|
||||||
|
%Block.extra;" >
|
||||||
|
<!ENTITY % Blk.class "%BlkNoTable.class;
|
||||||
|
%Table.class;
|
||||||
|
%Block.extra;" >
|
||||||
|
<!ENTITY % FlowNoTable.class "%Inline.class;
|
||||||
|
| %BlkNoTable.class;" >
|
||||||
|
|
||||||
|
<!-- pre content model, taken verbatim from the driver. It excludes
|
||||||
|
%InlPres.class; and %InlSpecial.class;, so b, i and img are not
|
||||||
|
permitted in pre, as in XHTML Mobile 1.2. -->
|
||||||
|
<!ENTITY % pre.content
|
||||||
|
"( #PCDATA
|
||||||
|
| %InlStruct.class;
|
||||||
|
%InlPhras.class;
|
||||||
|
%Anchor.class;
|
||||||
|
%Inline.extra; )*"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Document structure (SPEC.md 4.1) -->
|
||||||
|
<!--
|
||||||
|
Only body uses %Blk.class;, so a table can appear directly in the
|
||||||
|
body and nowhere else, and tables cannot nest (SPEC.md 4.2). body may
|
||||||
|
carry one class from the variant list (SPEC.md 5.3).
|
||||||
|
-->
|
||||||
|
<!ELEMENT html ( head, body ) >
|
||||||
|
<!ATTLIST html
|
||||||
|
xmlns CDATA #FIXED "http://www.w3.org/1999/xhtml"
|
||||||
|
xml:lang %LanguageCode.datatype; #IMPLIED
|
||||||
|
lang %LanguageCode.datatype; #IMPLIED
|
||||||
|
dir (ltr | rtl) #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT head ( title, meta, ( meta | link )* ) >
|
||||||
|
<!ELEMENT title ( #PCDATA )* >
|
||||||
|
<!ELEMENT body ( %Blk.class; )+ >
|
||||||
|
<!ATTLIST body
|
||||||
|
%Common.attrib;
|
||||||
|
class (mews-warm | mews-cool | mews-green | mews-mono) #IMPLIED
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Head elements (SPEC.md 3.3) -->
|
||||||
|
<!--
|
||||||
|
The head may contain only title, meta and link. The title comes
|
||||||
|
first and at least one meta is required; the conformance marker is a
|
||||||
|
meta (SPEC.md 3.2), but a DTD cannot check its name and content
|
||||||
|
together, so validators confirm the marker as a rule check (SPEC.md
|
||||||
|
9.1). link rel is limited to the uses SPEC.md 3.3 names: the
|
||||||
|
stylesheet, the icon and feed links.
|
||||||
|
-->
|
||||||
|
<!ELEMENT meta EMPTY >
|
||||||
|
<!ATTLIST meta
|
||||||
|
name NMTOKEN #IMPLIED
|
||||||
|
content CDATA #REQUIRED
|
||||||
|
>
|
||||||
|
<!ELEMENT link EMPTY >
|
||||||
|
<!ATTLIST link
|
||||||
|
rel (alternate | icon | stylesheet) #IMPLIED
|
||||||
|
href %URI.datatype; #IMPLIED
|
||||||
|
type CDATA #IMPLIED
|
||||||
|
title CDATA #IMPLIED
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Headings (SPEC.md 4.1) -->
|
||||||
|
<!ELEMENT h1 ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST h1
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT h2 ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST h2
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT h3 ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST h3
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT h4 ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST h4
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT h5 ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST h5
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT h6 ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST h6
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Text blocks (SPEC.md 4.1) -->
|
||||||
|
<!ELEMENT p ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST p
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT blockquote ( %BlkNoTable.class; )* >
|
||||||
|
<!ATTLIST blockquote
|
||||||
|
%Common.attrib;
|
||||||
|
cite %URI.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT pre %pre.content; >
|
||||||
|
<!ATTLIST pre
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT address ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST address
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT hr EMPTY >
|
||||||
|
<!ATTLIST hr
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Inline text (SPEC.md 4.1) -->
|
||||||
|
<!ELEMENT em ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST em
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT strong ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST strong
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT code ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST code
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT kbd ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST kbd
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT samp ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST samp
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT var ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST var
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT cite ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST cite
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT abbr ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST abbr
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT dfn ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST dfn
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT b ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST b
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT i ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST i
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT q ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST q
|
||||||
|
%Common.attrib;
|
||||||
|
cite %URI.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT br EMPTY >
|
||||||
|
<!ATTLIST br
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT a ( #PCDATA | %InlNoAnchor.class; )* >
|
||||||
|
<!ATTLIST a
|
||||||
|
%Common.attrib;
|
||||||
|
href %URI.datatype; #IMPLIED
|
||||||
|
hreflang %LanguageCode.datatype; #IMPLIED
|
||||||
|
rel CDATA #IMPLIED
|
||||||
|
type CDATA #IMPLIED
|
||||||
|
accesskey %Character.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT img EMPTY >
|
||||||
|
<!ATTLIST img
|
||||||
|
%Common.attrib;
|
||||||
|
src %URI.datatype; #REQUIRED
|
||||||
|
alt %Text.datatype; #REQUIRED
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Lists (SPEC.md 4.1) -->
|
||||||
|
<!ELEMENT ul ( li )+ >
|
||||||
|
<!ATTLIST ul
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT ol ( li )+ >
|
||||||
|
<!ATTLIST ol
|
||||||
|
%Common.attrib;
|
||||||
|
start %Number.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT dl ( dt | dd )+ >
|
||||||
|
<!ATTLIST dl
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT li ( #PCDATA | %FlowNoTable.class; )* >
|
||||||
|
<!ATTLIST li
|
||||||
|
%Common.attrib;
|
||||||
|
value %Number.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT dt ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST dt
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT dd ( #PCDATA | %FlowNoTable.class; )* >
|
||||||
|
<!ATTLIST dd
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Tables (SPEC.md 4.2) -->
|
||||||
|
<!--
|
||||||
|
As in the XHTML Mobile basic tables module, cells take
|
||||||
|
%FlowNoTable.class; and never a table, so tables cannot nest
|
||||||
|
(SPEC.md 4.2).
|
||||||
|
-->
|
||||||
|
<!ELEMENT table ( caption?, tr+ ) >
|
||||||
|
<!ATTLIST table
|
||||||
|
%Common.attrib;
|
||||||
|
summary %Text.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT caption ( #PCDATA | %Inline.class; )* >
|
||||||
|
<!ATTLIST caption
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT tr ( th | td )+ >
|
||||||
|
<!ATTLIST tr
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT th ( #PCDATA | %FlowNoTable.class; )* >
|
||||||
|
<!ATTLIST th
|
||||||
|
%Common.attrib;
|
||||||
|
rowspan %Number.datatype; "1"
|
||||||
|
colspan %Number.datatype; "1"
|
||||||
|
scope (row | col) #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT td ( #PCDATA | %FlowNoTable.class; )* >
|
||||||
|
<!ATTLIST td
|
||||||
|
%Common.attrib;
|
||||||
|
rowspan %Number.datatype; "1"
|
||||||
|
colspan %Number.datatype; "1"
|
||||||
|
scope (row | col) #IMPLIED
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Forms (SPEC.md 4.1, 4.2) -->
|
||||||
|
<!--
|
||||||
|
As in the XHTML MP forms module, form takes %BlkNoForm.class;, so
|
||||||
|
forms cannot nest. fieldset also takes %BlkNoForm.class;, so a
|
||||||
|
fieldset cannot reintroduce a form.
|
||||||
|
-->
|
||||||
|
<!ELEMENT form ( %BlkNoForm.class; )+ >
|
||||||
|
<!ATTLIST form
|
||||||
|
%Common.attrib;
|
||||||
|
action %URI.datatype; #REQUIRED
|
||||||
|
method (get | post) "get"
|
||||||
|
enctype CDATA "application/x-www-form-urlencoded"
|
||||||
|
>
|
||||||
|
<!ELEMENT fieldset ( #PCDATA | %Inline.class; | %BlkNoForm.class; )* >
|
||||||
|
<!ATTLIST fieldset
|
||||||
|
%Common.attrib;
|
||||||
|
>
|
||||||
|
<!ELEMENT label ( #PCDATA | %InlNoLabel.class; )* >
|
||||||
|
<!ATTLIST label
|
||||||
|
%Common.attrib;
|
||||||
|
for IDREF #IMPLIED
|
||||||
|
accesskey %Character.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT input EMPTY >
|
||||||
|
<!ATTLIST input
|
||||||
|
%Common.attrib;
|
||||||
|
type (text | password | checkbox | radio | submit | reset | hidden) "text"
|
||||||
|
name CDATA #IMPLIED
|
||||||
|
value CDATA #IMPLIED
|
||||||
|
checked (checked) #IMPLIED
|
||||||
|
size %Number.datatype; #IMPLIED
|
||||||
|
maxlength %Number.datatype; #IMPLIED
|
||||||
|
accesskey %Character.datatype; #IMPLIED
|
||||||
|
inputmode CDATA #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT select ( optgroup | option )+ >
|
||||||
|
<!ATTLIST select
|
||||||
|
%Common.attrib;
|
||||||
|
name CDATA #IMPLIED
|
||||||
|
multiple (multiple) #IMPLIED
|
||||||
|
size %Number.datatype; #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT optgroup ( option )+ >
|
||||||
|
<!ATTLIST optgroup
|
||||||
|
%Common.attrib;
|
||||||
|
label %Text.datatype; #REQUIRED
|
||||||
|
>
|
||||||
|
<!ELEMENT option ( #PCDATA )* >
|
||||||
|
<!ATTLIST option
|
||||||
|
%Common.attrib;
|
||||||
|
value CDATA #IMPLIED
|
||||||
|
selected (selected) #IMPLIED
|
||||||
|
>
|
||||||
|
<!ELEMENT textarea ( #PCDATA )* >
|
||||||
|
<!ATTLIST textarea
|
||||||
|
%Common.attrib;
|
||||||
|
name CDATA #IMPLIED
|
||||||
|
rows %Number.datatype; #REQUIRED
|
||||||
|
cols %Number.datatype; #REQUIRED
|
||||||
|
accesskey %Character.datatype; #IMPLIED
|
||||||
|
inputmode CDATA #IMPLIED
|
||||||
|
>
|
||||||
382
mews/fetch.py
Normal file
382
mews/fetch.py
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
"""Fetching pages that strangers ask the checker to look at.
|
||||||
|
|
||||||
|
Every URL reaching this module is author-supplied, so the service must not be
|
||||||
|
usable as a probe against the machine it runs on or its neighbours. Addresses
|
||||||
|
are vetted before the connection and the connection is pinned to the vetted
|
||||||
|
address, so a second DNS answer cannot redirect it (DNS rebinding).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from publicsuffixlist import PublicSuffixList
|
||||||
|
|
||||||
|
# Read caps, in bytes. The page cap is above the 256 KB that SPEC.md 4.3 makes a
|
||||||
|
# MUST so an oversized page is reported as too large rather than unreachable.
|
||||||
|
PAGE_CAP = 288 * 1024
|
||||||
|
IMAGE_CAP = 64 * 1024
|
||||||
|
CSS_CAP = 256 * 1024
|
||||||
|
|
||||||
|
MAX_REDIRECTS = 3
|
||||||
|
MAX_URL_LENGTH = 2048
|
||||||
|
USER_AGENT = "mews.page checker (+https://mews.page/about)"
|
||||||
|
|
||||||
|
_PSL = PublicSuffixList()
|
||||||
|
|
||||||
|
# Ranges ipaddress does not classify but which must never be reached: carrier
|
||||||
|
# NAT (which is also Tailscale's range), IETF protocol assignment, benchmarking,
|
||||||
|
# the documentation ranges, and the IPv6 transition mechanisms that tunnel an
|
||||||
|
# IPv4 address inside an address that looks global.
|
||||||
|
_BLOCKED_NETS = [
|
||||||
|
ipaddress.ip_network(net)
|
||||||
|
for net in (
|
||||||
|
"100.64.0.0/10",
|
||||||
|
"192.0.0.0/24",
|
||||||
|
"198.18.0.0/15",
|
||||||
|
"192.0.2.0/24",
|
||||||
|
"198.51.100.0/24",
|
||||||
|
"203.0.113.0/24",
|
||||||
|
"240.0.0.0/4",
|
||||||
|
"255.255.255.255/32",
|
||||||
|
"2001:db8::/32",
|
||||||
|
"2002::/16",
|
||||||
|
"2001::/32",
|
||||||
|
"64:ff9b::/96",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Hosts whose own addresses must be unreachable, as CIDRs. The deployment sets
|
||||||
|
# this to the machine's public address so a submission cannot be aimed at a
|
||||||
|
# service sharing the box.
|
||||||
|
_EXTRA_NETS = [
|
||||||
|
ipaddress.ip_network(net.strip())
|
||||||
|
for net in os.environ.get("MEWS_BLOCK_NETS", "").split(",")
|
||||||
|
if net.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOCAL_SUFFIXES = (".local", ".internal", ".home.arpa", ".localhost")
|
||||||
|
|
||||||
|
# What a host name may be made of. Checking this first means a typo gets a
|
||||||
|
# clearer answer than a complaint about public suffixes.
|
||||||
|
_HOSTNAME = re.compile(
|
||||||
|
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UrlError(Exception):
|
||||||
|
"""A URL the checker will not fetch. The message is shown to the author."""
|
||||||
|
|
||||||
|
|
||||||
|
class FetchError(Exception):
|
||||||
|
"""A fetch that produced no page. The message is shown to the author."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Fetched:
|
||||||
|
"""One response the checker read, with the body it kept."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
status: int
|
||||||
|
headers: httpx.Headers
|
||||||
|
body: bytes
|
||||||
|
truncated: bool
|
||||||
|
scheme_downgraded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def registered_domain(host: str) -> str | None:
|
||||||
|
"""Return the registrable domain of host, or None when it has no public suffix.
|
||||||
|
|
||||||
|
Comparing registrable domains rather than hostnames is what makes the
|
||||||
|
same-site rules in SPEC.md 4.2 and 7.4 mean anything: a free subdomain host
|
||||||
|
would otherwise let any two unrelated sites count as one.
|
||||||
|
"""
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
name = host.strip().rstrip(".").lower().encode("idna").decode("ascii")
|
||||||
|
except (UnicodeError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return _PSL.privatesuffix(name)
|
||||||
|
|
||||||
|
|
||||||
|
def same_site(a: str, b: str) -> bool:
|
||||||
|
"""Whether two hosts share a registrable domain."""
|
||||||
|
first = registered_domain(a)
|
||||||
|
return first is not None and first == registered_domain(b)
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_url(raw: str, *, allow_loopback: bool = False) -> str:
|
||||||
|
"""Return a URL the checker is willing to fetch, or raise UrlError.
|
||||||
|
|
||||||
|
This runs before any name lookup, so a rejected URL costs nothing. With
|
||||||
|
allow_loopback the rules relax enough to reach a test server on this
|
||||||
|
machine: an address literal, localhost, and any port.
|
||||||
|
"""
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise UrlError("Enter the address of a page to check.")
|
||||||
|
if len(text) > MAX_URL_LENGTH:
|
||||||
|
raise UrlError("That address is too long.")
|
||||||
|
if "://" not in text:
|
||||||
|
text = "https://" + text
|
||||||
|
|
||||||
|
parts = urlsplit(text)
|
||||||
|
if parts.scheme not in ("http", "https"):
|
||||||
|
raise UrlError("Enter an address that starts with http:// or https://.")
|
||||||
|
if "@" in parts.netloc:
|
||||||
|
raise UrlError("Enter an address without a user name in it.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
host, port = parts.hostname, parts.port
|
||||||
|
except ValueError as error:
|
||||||
|
raise UrlError("That address has a port the checker can't read.") from error
|
||||||
|
if not host:
|
||||||
|
raise UrlError(
|
||||||
|
"That doesn't look like a web address. Enter the full address "
|
||||||
|
"of a page, like https://example.com/"
|
||||||
|
)
|
||||||
|
if port is not None and port not in (80, 443) and not allow_loopback:
|
||||||
|
raise UrlError("The checker reads pages on the usual web ports only.")
|
||||||
|
|
||||||
|
host = host.lower().rstrip(".")
|
||||||
|
if _is_ip_literal(host):
|
||||||
|
if not (allow_loopback and _is_loopback_literal(host)):
|
||||||
|
raise UrlError("Enter a domain name rather than an IP address.")
|
||||||
|
elif host == "localhost" or host.endswith(_LOCAL_SUFFIXES):
|
||||||
|
if not allow_loopback:
|
||||||
|
raise UrlError("That address is only reachable on a local network.")
|
||||||
|
elif not _HOSTNAME.match(_ascii(host)):
|
||||||
|
raise UrlError(
|
||||||
|
"That doesn't look like a web address. Enter the full address "
|
||||||
|
"of a page, like https://example.com/"
|
||||||
|
)
|
||||||
|
elif registered_domain(host) is None:
|
||||||
|
raise UrlError("That domain name isn't one the checker can reach.")
|
||||||
|
|
||||||
|
netloc = host if port is None else f"{host}:{port}"
|
||||||
|
return urlunsplit((parts.scheme, netloc, parts.path or "/", parts.query, ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii(host: str) -> str:
|
||||||
|
"""Return the punycode form of a host name, or the name unchanged."""
|
||||||
|
try:
|
||||||
|
return host.encode("idna").decode("ascii")
|
||||||
|
except (UnicodeError, UnicodeDecodeError):
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_literal(host: str) -> bool:
|
||||||
|
"""Whether host is an address literal in any of the forms a parser accepts."""
|
||||||
|
candidate = host.strip("[]")
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(candidate)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
# Decimal, octal and hex integer forms of an IPv4 address, which urlsplit
|
||||||
|
# leaves alone but a resolver would accept.
|
||||||
|
if host.isdigit():
|
||||||
|
return True
|
||||||
|
bare = host.replace(".", "")
|
||||||
|
return bare.startswith(("0x", "0X")) or (
|
||||||
|
host.startswith("0") and len(host) > 1 and bare.isdigit()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_loopback_literal(host: str) -> bool:
|
||||||
|
"""Whether host is a literal address on this machine."""
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(host.strip("[]")).is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _embedded(address: ipaddress.IPv6Address) -> ipaddress.IPv4Address | None:
|
||||||
|
"""Return the IPv4 address a transition mechanism hides inside an IPv6 one."""
|
||||||
|
for attribute in ("ipv4_mapped", "sixtofour"):
|
||||||
|
value = getattr(address, attribute, None)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
teredo = getattr(address, "teredo", None)
|
||||||
|
if teredo:
|
||||||
|
return teredo[1]
|
||||||
|
if address in ipaddress.ip_network("64:ff9b::/96"):
|
||||||
|
return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vet_address(address: str, *, allow_loopback: bool = False) -> None:
|
||||||
|
"""Raise UrlError unless address is a public one the checker may connect to."""
|
||||||
|
ip = ipaddress.ip_address(address)
|
||||||
|
if allow_loopback and ip.is_loopback:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_unspecified
|
||||||
|
or any(ip in net for net in _BLOCKED_NETS)
|
||||||
|
or any(ip in net for net in _EXTRA_NETS)
|
||||||
|
):
|
||||||
|
raise UrlError("That address isn't on the public internet.")
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address):
|
||||||
|
inner = _embedded(ip)
|
||||||
|
if inner is not None:
|
||||||
|
vet_address(str(inner), allow_loopback=allow_loopback)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(url: str, *, allow_loopback: bool = False) -> str:
|
||||||
|
"""Return one vetted address for the URL's host, rejecting the host if any fails.
|
||||||
|
|
||||||
|
Every answer has to pass: a host that resolves to one public and one private
|
||||||
|
address would otherwise be a coin toss.
|
||||||
|
"""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
host = parts.hostname or ""
|
||||||
|
port = parts.port or (443 if parts.scheme == "https" else 80)
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror as error:
|
||||||
|
raise FetchError(
|
||||||
|
"We couldn't find that domain name. Check the address and try again."
|
||||||
|
) from error
|
||||||
|
addresses = [info[4][0] for info in infos]
|
||||||
|
if not addresses:
|
||||||
|
raise FetchError("We couldn't find that domain name.")
|
||||||
|
for address in addresses:
|
||||||
|
vet_address(address, allow_loopback=allow_loopback)
|
||||||
|
return addresses[0]
|
||||||
|
|
||||||
|
|
||||||
|
class Fetcher:
|
||||||
|
"""Reads pages and their sub-resources, under a time and byte budget.
|
||||||
|
|
||||||
|
One Fetcher serves one check, so the whole-check budget is shared across the
|
||||||
|
page, its stylesheet and its images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, allow_loopback: bool = False, budget: float = 45.0):
|
||||||
|
self.allow_loopback = allow_loopback
|
||||||
|
self._deadline = time.monotonic() + budget
|
||||||
|
self._client = httpx.Client(
|
||||||
|
follow_redirects=False,
|
||||||
|
trust_env=False,
|
||||||
|
http2=False,
|
||||||
|
verify=True,
|
||||||
|
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
|
||||||
|
limits=httpx.Limits(max_connections=4, max_keepalive_connections=2),
|
||||||
|
headers={
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self) -> "Fetcher":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the underlying connections."""
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
cap: int = PAGE_CAP,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> Fetched:
|
||||||
|
"""Fetch one URL, following redirects by hand and vetting each hop."""
|
||||||
|
current = normalise_url(url, allow_loopback=self.allow_loopback)
|
||||||
|
downgraded = False
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
response = self._request(current, cap=cap, headers=headers)
|
||||||
|
if response.status not in (301, 302, 303, 307, 308):
|
||||||
|
if downgraded:
|
||||||
|
return replace(response, scheme_downgraded=True)
|
||||||
|
return response
|
||||||
|
location = response.headers.get("location", "")
|
||||||
|
if not location:
|
||||||
|
raise FetchError("That page redirects without saying where to.")
|
||||||
|
target = normalise_url(
|
||||||
|
str(httpx.URL(current).join(location)),
|
||||||
|
allow_loopback=self.allow_loopback,
|
||||||
|
)
|
||||||
|
was_secure = urlsplit(current).scheme == "https"
|
||||||
|
if was_secure and urlsplit(target).scheme == "http":
|
||||||
|
downgraded = True
|
||||||
|
current = target
|
||||||
|
raise FetchError("That page redirects too many times.")
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
cap: int,
|
||||||
|
headers: dict[str, str] | None,
|
||||||
|
) -> Fetched:
|
||||||
|
"""Make one pinned request, streaming the body up to cap bytes."""
|
||||||
|
self._check_deadline()
|
||||||
|
parts = urlsplit(url)
|
||||||
|
host = parts.hostname or ""
|
||||||
|
port = parts.port
|
||||||
|
address = resolve(url, allow_loopback=self.allow_loopback)
|
||||||
|
literal = f"[{address}]" if ":" in address else address
|
||||||
|
pinned = urlunsplit(
|
||||||
|
(
|
||||||
|
parts.scheme,
|
||||||
|
literal if port is None else f"{literal}:{port}",
|
||||||
|
parts.path,
|
||||||
|
parts.query,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
request_headers = {"Host": parts.netloc, **(headers or {})}
|
||||||
|
extensions = {"sni_hostname": host} if parts.scheme == "https" else {}
|
||||||
|
try:
|
||||||
|
with self._client.stream(
|
||||||
|
"GET", pinned, headers=request_headers, extensions=extensions
|
||||||
|
) as response:
|
||||||
|
declared = response.headers.get("content-length")
|
||||||
|
if declared and declared.isdigit() and int(declared) > cap:
|
||||||
|
raise FetchError("That page is too large for the checker to read.")
|
||||||
|
body, truncated = self._read(response, cap)
|
||||||
|
except httpx.TimeoutException as error:
|
||||||
|
raise FetchError("That page took too long to answer.") from error
|
||||||
|
except httpx.HTTPError as error:
|
||||||
|
raise FetchError("We couldn't connect to that site.") from error
|
||||||
|
return Fetched(url, response.status_code, response.headers, body, truncated)
|
||||||
|
|
||||||
|
def _read(self, response: httpx.Response, cap: int) -> tuple[bytes, bool]:
|
||||||
|
"""Read a streamed body, stopping at cap decoded bytes."""
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
self._check_deadline()
|
||||||
|
chunks.append(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
if total > cap:
|
||||||
|
return b"".join(chunks)[:cap], True
|
||||||
|
body = b"".join(chunks)
|
||||||
|
encoded = response.num_bytes_downloaded or len(body)
|
||||||
|
# A body that expanded enormously from a small download is a compression
|
||||||
|
# bomb, not a page.
|
||||||
|
if encoded and len(body) > 100 * encoded and len(body) > 64 * 1024:
|
||||||
|
raise FetchError("That page is compressed in a way the checker won't read.")
|
||||||
|
return body, False
|
||||||
|
|
||||||
|
def _check_deadline(self) -> None:
|
||||||
|
if time.monotonic() > self._deadline:
|
||||||
|
raise FetchError("Checking that page took too long.")
|
||||||
976
mews/lint.py
Normal file
976
mews/lint.py
Normal file
|
|
@ -0,0 +1,976 @@
|
||||||
|
"""The Mews page validator.
|
||||||
|
|
||||||
|
SPEC.md 9.1 defines conformance as two checks: the page validates against
|
||||||
|
dtd/mews-0.1.dtd, and it follows the rules a DTD cannot express. This module
|
||||||
|
runs both and reports every failure with its spec section.
|
||||||
|
|
||||||
|
Failures at MUST level mean the page does not conform. Failures at SHOULD level
|
||||||
|
are warnings: they are worth fixing but do not make a page non-conforming.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import html.entities
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from mews import VERSION, css as mews_css
|
||||||
|
from mews.fetch import (
|
||||||
|
CSS_CAP,
|
||||||
|
IMAGE_CAP,
|
||||||
|
Fetched,
|
||||||
|
Fetcher,
|
||||||
|
FetchError,
|
||||||
|
UrlError,
|
||||||
|
normalise_url,
|
||||||
|
same_site,
|
||||||
|
)
|
||||||
|
|
||||||
|
XHTML = "http://www.w3.org/1999/xhtml"
|
||||||
|
XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang"
|
||||||
|
|
||||||
|
MARKER = "mews-profile"
|
||||||
|
CANONICAL_STYLESHEET = "https://mews.page/mews-0.1.css"
|
||||||
|
|
||||||
|
OK = 200
|
||||||
|
MAX_FINDINGS = 50
|
||||||
|
MAX_IMAGES = 20
|
||||||
|
|
||||||
|
SIZE_MUST = 256 * 1024
|
||||||
|
SIZE_SHOULD = 64 * 1024
|
||||||
|
SIZE_TOTAL = 320 * 1024
|
||||||
|
IMAGE_SHOULD = 50 * 1024
|
||||||
|
|
||||||
|
XML_DECLARATION = re.compile(rb'^<\?xml version="1\.0" encoding="(?i:UTF-8)"\?>')
|
||||||
|
DOCTYPE = re.compile(
|
||||||
|
rb'<!DOCTYPE\s+html\s+PUBLIC\s+"-//WAPFORUM//DTD XHTML Mobile 1\.2//EN"\s+'
|
||||||
|
rb'"http://www\.openmobilealliance\.org/tech/DTD/xhtml-mobile12\.dtd"\s*>'
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_META = ("mews-profile", "description", "author", "viewport")
|
||||||
|
ALLOWED_RELS = ("stylesheet", "icon", "alternate")
|
||||||
|
|
||||||
|
# Magic bytes for the three formats SPEC.md 4.2 recommends.
|
||||||
|
IMAGE_MAGIC = {
|
||||||
|
b"GIF87a": "GIF",
|
||||||
|
b"GIF89a": "GIF",
|
||||||
|
b"\x89PNG\r\n\x1a\n": "PNG",
|
||||||
|
b"\xff\xd8\xff": "JPEG",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Check:
|
||||||
|
"""One thing the validator looks at, and the spec section it comes from."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
section: str
|
||||||
|
level: str
|
||||||
|
what: str
|
||||||
|
|
||||||
|
|
||||||
|
CHECKS: tuple[Check, ...] = (
|
||||||
|
Check("xml-declaration", "3.1", "must", "UTF-8 XML declaration, no BOM"),
|
||||||
|
Check("doctype", "3.1", "must", "the XHTML-MP 1.2 doctype, exactly"),
|
||||||
|
Check("internal-subset", "3.1", "must", "no internal DTD subset"),
|
||||||
|
Check("well-formed", "3", "must", "the page parses as XML"),
|
||||||
|
Check("root-element", "3.1", "must", "html root in the XHTML namespace"),
|
||||||
|
Check("lang", "3.1", "should", "xml:lang and lang agree on html"),
|
||||||
|
Check("dtd-element", "4.1", "must", "only elements from 4.1"),
|
||||||
|
Check("dtd-attribute", "4.1", "must", "only attributes from 4.1"),
|
||||||
|
Check("dtd-nesting", "4.1", "must", "elements nest as the DTD allows"),
|
||||||
|
Check("dtd-content", "4.1", "must", "element content follows the DTD"),
|
||||||
|
Check("dtd-value", "4.1", "must", "attribute values from the permitted set"),
|
||||||
|
Check("dtd-required-attribute", "4.1", "must", "required attributes present"),
|
||||||
|
Check("dtd-head", "3.3", "must", "the head holds only what 3.3 permits"),
|
||||||
|
Check("dtd-nested-table", "4.2", "must", "tables are not nested"),
|
||||||
|
Check("dtd-variant", "5.3", "must", "body class is one of the variants"),
|
||||||
|
Check("dtd-styling", "5", "must", "no author styling"),
|
||||||
|
Check("dtd-other", "4.1", "must", "the page matches the Mews DTD"),
|
||||||
|
Check("marker", "3.2", "must", "the conformance marker is present once"),
|
||||||
|
Check("marker-version", "3.2", "must", "the marker names a known version"),
|
||||||
|
Check("meta-name", "3.3", "must", "only the meta names 3.3 permits"),
|
||||||
|
Check("link-rel", "3.3", "must", "only the link kinds 3.3 permits"),
|
||||||
|
Check("link-stylesheet-count", "3.3", "must", "at most one stylesheet link"),
|
||||||
|
Check("link-alternate-type", "3.3", "must", "feed links are Atom"),
|
||||||
|
Check("icon-offsite", "3.3", "must", "the icon is on the page's own site"),
|
||||||
|
Check("viewport", "3.3", "should", "the viewport meta is present"),
|
||||||
|
Check("size-must", "4.3", "must", "markup under 256 KB"),
|
||||||
|
Check("size-should", "4.3", "should", "markup under 64 KB"),
|
||||||
|
Check("size-total", "4.3", "should", "page plus images under 320 KB"),
|
||||||
|
Check("image-data-uri", "4.2", "must", "no data: URI images"),
|
||||||
|
Check("image-offsite", "4.2", "must", "images are on the page's own site"),
|
||||||
|
Check("image-format", "4.2", "should", "images are GIF, JPEG or PNG"),
|
||||||
|
Check("image-size", "4.2", "should", "each image under 50 KB"),
|
||||||
|
Check("image-unreachable", "4.2", "should", "images load"),
|
||||||
|
Check("image-metadata", "4.2", "should", "images carry no camera metadata"),
|
||||||
|
Check("images-not-checked", "4.2", "should", "how many images were checked"),
|
||||||
|
Check("stylesheet-missing", "5.2", "should", "the default stylesheet is linked"),
|
||||||
|
Check("stylesheet-canonical", "5.2", "should", "the site hosts its own copy"),
|
||||||
|
Check("stylesheet-modified", "5.2", "must", "the copy is unmodified"),
|
||||||
|
Check("stylesheet-import", "5.2", "must", "the copy has no @import"),
|
||||||
|
Check("stylesheet-unreachable", "5.2", "should", "the stylesheet loads"),
|
||||||
|
Check("font-offsite", "5.2", "must", "@font-face fonts are on the own site"),
|
||||||
|
Check("stylesheet-offsite", "7.4", "must", "the stylesheet is on the own site"),
|
||||||
|
Check("content-type", "7.1", "should", "a page content type"),
|
||||||
|
Check("https", "7.2", "should", "served over HTTPS"),
|
||||||
|
Check("validators", "7.3", "should", "Last-Modified or ETag is sent"),
|
||||||
|
Check("cookie", "7.4", "must", "no cookies are set"),
|
||||||
|
)
|
||||||
|
|
||||||
|
BY_CODE = {check.code: check for check in CHECKS}
|
||||||
|
|
||||||
|
# Rules in sections 3 to 7 this validator deliberately leaves alone. Together
|
||||||
|
# with CHECKS this covers every MUST and SHOULD in those sections; tests fail if
|
||||||
|
# a section appears in neither.
|
||||||
|
NOT_CHECKED = {
|
||||||
|
"5.1": "The default stylesheet is compared byte for byte (5.2), which "
|
||||||
|
"covers whether its base rules are valid WAP CSS.",
|
||||||
|
"6.1": "Dated links are a client convention; the validator reads no feeds.",
|
||||||
|
"6.2": "Atom feed contents are a client convention; only the link type is checked.",
|
||||||
|
"7.2": "Whether a site redirects HTTP to HTTPS in a way old handsets can "
|
||||||
|
"follow cannot be told from one request.",
|
||||||
|
"4.2": "Whether text appears only inside an image cannot be told from markup.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Finding:
|
||||||
|
"""One rule a page broke."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
location: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def section(self) -> str:
|
||||||
|
"""Return the spec section this finding comes from."""
|
||||||
|
return BY_CODE[self.code].section
|
||||||
|
|
||||||
|
@property
|
||||||
|
def level(self) -> str:
|
||||||
|
"""Return either must or should."""
|
||||||
|
return BY_CODE[self.code].level
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
where = f" ({self.location})" if self.location else ""
|
||||||
|
return f"Section {self.section} — {self.message}{where}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
"""What the validator found, plus the facts the directory needs."""
|
||||||
|
|
||||||
|
url: str | None = None
|
||||||
|
findings: list[Finding] = field(default_factory=list)
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
title: str = ""
|
||||||
|
description: str = ""
|
||||||
|
language: str = ""
|
||||||
|
size: int = 0
|
||||||
|
|
||||||
|
def add(self, code: str, message: str, location: str = "") -> None:
|
||||||
|
"""Record one finding, up to the report cap."""
|
||||||
|
if len(self.findings) < MAX_FINDINGS:
|
||||||
|
self.findings.append(Finding(code, message, location))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failures(self) -> list[Finding]:
|
||||||
|
"""Return the findings that make the page non-conforming."""
|
||||||
|
return [f for f in self.findings if f.level == "must"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def warnings(self) -> list[Finding]:
|
||||||
|
"""Return the findings worth fixing that still leave the page conforming."""
|
||||||
|
return [f for f in self.findings if f.level == "should"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conforms(self) -> bool:
|
||||||
|
"""Say whether the page follows every MUST rule the validator checks."""
|
||||||
|
return not self.failures
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_declarations() -> str:
|
||||||
|
"""Declare the entity set the XHTML-MP doctype would have defined.
|
||||||
|
|
||||||
|
SPEC.md 9.1 says clients must not fetch the doctype, and the local copy of
|
||||||
|
the driver DTD pulls its modules from w3.org, so the entities are supplied
|
||||||
|
from the stdlib's HTML 4 table instead — the same 252 names.
|
||||||
|
"""
|
||||||
|
return "".join(
|
||||||
|
f'<!ENTITY {name} "&#{code};">'
|
||||||
|
for code, name in sorted(html.entities.codepoint2name.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Resolver(etree.Resolver):
|
||||||
|
"""Resolves the XHTML-MP doctype to entity declarations and nothing else."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entities = _entity_declarations()
|
||||||
|
|
||||||
|
def resolve(self, system_url, public_id, context):
|
||||||
|
"""Answer the parser's request for an external entity."""
|
||||||
|
if public_id == "-//WAPFORUM//DTD XHTML Mobile 1.2//EN":
|
||||||
|
return self.resolve_string(self._entities, context)
|
||||||
|
# With no_network set, returning None makes any other doctype fail.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parser(*, expand_entities: bool) -> etree.XMLParser:
|
||||||
|
"""Build an XML parser that never reaches the network."""
|
||||||
|
parser = etree.XMLParser(
|
||||||
|
load_dtd=expand_entities,
|
||||||
|
resolve_entities=expand_entities,
|
||||||
|
no_network=True,
|
||||||
|
dtd_validation=False,
|
||||||
|
attribute_defaults=False,
|
||||||
|
huge_tree=False,
|
||||||
|
)
|
||||||
|
if expand_entities:
|
||||||
|
parser.resolvers.add(_Resolver())
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
_DTD: etree.DTD | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def mews_dtd() -> etree.DTD:
|
||||||
|
"""Return the Mews DTD, loaded once per process."""
|
||||||
|
global _DTD # noqa: PLW0603
|
||||||
|
if _DTD is None:
|
||||||
|
with resources.as_file(
|
||||||
|
resources.files("mews.data").joinpath("mews-0.1.dtd")
|
||||||
|
) as path:
|
||||||
|
_DTD = etree.DTD(str(path))
|
||||||
|
return _DTD
|
||||||
|
|
||||||
|
|
||||||
|
def local(element: etree._Element) -> str:
|
||||||
|
"""Return an element's name without its namespace."""
|
||||||
|
return etree.QName(element).localname
|
||||||
|
|
||||||
|
|
||||||
|
# --- The DTD check -------------------------------------------------------
|
||||||
|
|
||||||
|
UNDECLARED_ELEMENT = re.compile(r"^No declaration for element (\S+)")
|
||||||
|
UNDECLARED_ATTRIBUTE = re.compile(
|
||||||
|
r"^No declaration for attribute (\S+) of element (\S+)"
|
||||||
|
)
|
||||||
|
NOT_ALLOWED_IN = re.compile(
|
||||||
|
r"^Element (\S+) is not declared in (\S+) list of possible children"
|
||||||
|
)
|
||||||
|
CONTENT_MODEL = re.compile(
|
||||||
|
r"^Element (\S+) content does not follow the DTD, expecting (.*?), got \(?(.*?)\)?$"
|
||||||
|
)
|
||||||
|
BAD_VALUE = re.compile(
|
||||||
|
r'^Value "(.*?)" for attribute (\S+) of (\S+) is not among the enumerated set'
|
||||||
|
)
|
||||||
|
MISSING_ATTRIBUTE = re.compile(r"^Element (\S+) does not carry attribute (\S+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerated_values(element: str, attribute: str) -> list[str]:
|
||||||
|
"""Return the values the DTD permits for an attribute, read from the DTD."""
|
||||||
|
for declared in mews_dtd().iterelements():
|
||||||
|
if declared.name != element:
|
||||||
|
continue
|
||||||
|
for attr in declared.iterattributes():
|
||||||
|
if attr.name == attribute:
|
||||||
|
return list(attr.itervalues() or [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _dtd_findings(report: Report, tree: etree._ElementTree) -> None:
|
||||||
|
"""Validate against the Mews DTD and turn libxml2's errors into findings."""
|
||||||
|
dtd = mews_dtd()
|
||||||
|
if dtd.validate(tree):
|
||||||
|
return
|
||||||
|
|
||||||
|
errors = [(entry.line, entry.message) for entry in dtd.error_log]
|
||||||
|
undeclared = {
|
||||||
|
match.group(1)
|
||||||
|
for _, message in errors
|
||||||
|
for match in [UNDECLARED_ELEMENT.match(message)]
|
||||||
|
if match
|
||||||
|
}
|
||||||
|
|
||||||
|
for line, message in errors:
|
||||||
|
finding = _map_error(message, undeclared)
|
||||||
|
if finding is not None:
|
||||||
|
code, text = finding
|
||||||
|
report.add(code, text, f"line {line}")
|
||||||
|
|
||||||
|
|
||||||
|
def _map_error(message: str, undeclared: set[str]) -> tuple[str, str] | None:
|
||||||
|
"""Map one libxml2 message to a code and a plain sentence, or drop it.
|
||||||
|
|
||||||
|
One mistake makes libxml2 say several things: an unknown element is also an
|
||||||
|
unknown attribute and a content-model break. Only the clearest is kept.
|
||||||
|
"""
|
||||||
|
match = UNDECLARED_ELEMENT.match(message)
|
||||||
|
if match:
|
||||||
|
name = match.group(1)
|
||||||
|
if name == "style":
|
||||||
|
return (
|
||||||
|
"dtd-styling",
|
||||||
|
"The <style> element isn't allowed. Mews pages carry no author "
|
||||||
|
"styling, so readers control how a page looks.",
|
||||||
|
)
|
||||||
|
if name == "script":
|
||||||
|
return (
|
||||||
|
"dtd-element",
|
||||||
|
"The <script> element isn't allowed. Mews clients run no "
|
||||||
|
"scripts, so a page has to work without them.",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"dtd-element",
|
||||||
|
f"The <{name}> element isn't allowed. Remove it, or use an element "
|
||||||
|
"from section 4.1.",
|
||||||
|
)
|
||||||
|
|
||||||
|
match = UNDECLARED_ATTRIBUTE.match(message)
|
||||||
|
if match:
|
||||||
|
attribute, element = match.group(1), match.group(2)
|
||||||
|
if element in undeclared:
|
||||||
|
return None
|
||||||
|
if attribute == "style":
|
||||||
|
return (
|
||||||
|
"dtd-styling",
|
||||||
|
f"The style attribute isn't allowed on <{element}>. Mews pages "
|
||||||
|
"carry no author styling.",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"dtd-attribute",
|
||||||
|
f"The {attribute} attribute isn't allowed on <{element}>. Remove it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
match = NOT_ALLOWED_IN.match(message)
|
||||||
|
if match:
|
||||||
|
child, parent = match.group(1), match.group(2)
|
||||||
|
if child in undeclared:
|
||||||
|
return None
|
||||||
|
if child == "table":
|
||||||
|
return (
|
||||||
|
"dtd-nested-table",
|
||||||
|
"Tables can't be nested. Move the inner table out, or use a list.",
|
||||||
|
)
|
||||||
|
return ("dtd-nesting", f"<{child}> can't go inside <{parent}>.")
|
||||||
|
|
||||||
|
match = CONTENT_MODEL.match(message)
|
||||||
|
if match:
|
||||||
|
element, got = match.group(1), match.group(3).split()
|
||||||
|
if any(name in undeclared for name in got):
|
||||||
|
return None
|
||||||
|
if element == "head":
|
||||||
|
return (
|
||||||
|
"dtd-head",
|
||||||
|
"The head can hold only a title, then meta and link elements, "
|
||||||
|
"with the title first.",
|
||||||
|
)
|
||||||
|
if not got:
|
||||||
|
if element == "body":
|
||||||
|
return ("dtd-content", "The page has nothing in its body.")
|
||||||
|
return (
|
||||||
|
"dtd-content",
|
||||||
|
f"<{element}> is empty. Either fill it or remove it.",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"dtd-content",
|
||||||
|
f"<{element}> can't hold what's inside it here.",
|
||||||
|
)
|
||||||
|
|
||||||
|
match = BAD_VALUE.match(message)
|
||||||
|
if match:
|
||||||
|
value, attribute, element = match.groups()
|
||||||
|
if (element, attribute) == ("link", "rel"):
|
||||||
|
return (
|
||||||
|
"link-rel",
|
||||||
|
f'The link with rel="{value}" isn\'t allowed. A head may hold '
|
||||||
|
"the stylesheet link, an icon and a feed link.",
|
||||||
|
)
|
||||||
|
if (element, attribute) == ("body", "class"):
|
||||||
|
return (
|
||||||
|
"dtd-variant",
|
||||||
|
f'"{value}" isn\'t a body variant. Use mews-warm, mews-cool, '
|
||||||
|
"mews-green or mews-mono, or leave class out.",
|
||||||
|
)
|
||||||
|
allowed = ", ".join(_enumerated_values(element, attribute))
|
||||||
|
return (
|
||||||
|
"dtd-value",
|
||||||
|
f'"{value}" isn\'t allowed for {attribute} on <{element}>. '
|
||||||
|
+ (f"Use one of: {allowed}." if allowed else "Remove it."),
|
||||||
|
)
|
||||||
|
|
||||||
|
match = MISSING_ATTRIBUTE.match(message)
|
||||||
|
if match:
|
||||||
|
element, attribute = match.groups()
|
||||||
|
if element == "img" and attribute == "alt":
|
||||||
|
return (
|
||||||
|
"dtd-required-attribute",
|
||||||
|
'Every image needs an alt attribute: a description, or alt="" '
|
||||||
|
"when the image is decoration.",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"dtd-required-attribute",
|
||||||
|
f"<{element}> needs a {attribute} attribute.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ("dtd-other", f"This page doesn't match the Mews DTD ({message}).")
|
||||||
|
|
||||||
|
|
||||||
|
# --- The rule checks -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _prologue(report: Report, data: bytes) -> tuple[bool, bool]:
|
||||||
|
"""Check the bytes before the root element.
|
||||||
|
|
||||||
|
Returns whether the page can be parsed at all and whether its entities may
|
||||||
|
be expanded. This runs first on purpose: a page carrying its own entity
|
||||||
|
declarations is refused before any parser sees them.
|
||||||
|
"""
|
||||||
|
head = data[:2048]
|
||||||
|
if head.startswith(b"\xef\xbb\xbf"):
|
||||||
|
report.add(
|
||||||
|
"xml-declaration",
|
||||||
|
"Remove the byte order mark at the start of the file. A Mews page "
|
||||||
|
"starts with the XML declaration.",
|
||||||
|
)
|
||||||
|
head = head[3:]
|
||||||
|
if not XML_DECLARATION.match(head):
|
||||||
|
report.add(
|
||||||
|
"xml-declaration",
|
||||||
|
'Start the page with <?xml version="1.0" encoding="UTF-8"?>.',
|
||||||
|
)
|
||||||
|
|
||||||
|
start = head.find(b"<!DOCTYPE")
|
||||||
|
if start == -1:
|
||||||
|
report.add(
|
||||||
|
"doctype",
|
||||||
|
"Add the XHTML Mobile 1.2 doctype after the XML declaration, as "
|
||||||
|
"section 3.1 shows.",
|
||||||
|
)
|
||||||
|
return True, False
|
||||||
|
|
||||||
|
end = head.find(b">", start)
|
||||||
|
if b"[" in head[start : end if end != -1 else len(head)]:
|
||||||
|
report.add(
|
||||||
|
"internal-subset",
|
||||||
|
"Remove the extra declarations from the doctype. A Mews page uses "
|
||||||
|
"the doctype from section 3.1 and nothing else.",
|
||||||
|
)
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
if not DOCTYPE.match(head, start):
|
||||||
|
report.add(
|
||||||
|
"doctype",
|
||||||
|
"Use the doctype from section 3.1 exactly, pointing at the XHTML "
|
||||||
|
"Mobile 1.2 DTD.",
|
||||||
|
)
|
||||||
|
return True, False
|
||||||
|
return True, True
|
||||||
|
|
||||||
|
|
||||||
|
def _document_findings(report: Report, root: etree._Element) -> None:
|
||||||
|
"""Check the root element and the head against sections 3.1 to 3.3."""
|
||||||
|
if root.tag != f"{{{XHTML}}}html":
|
||||||
|
report.add(
|
||||||
|
"root-element",
|
||||||
|
'The root element must be <html xmlns="http://www.w3.org/1999/xhtml">.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
xml_lang, lang = root.get(XML_LANG), root.get("lang")
|
||||||
|
if not xml_lang or not lang or xml_lang != lang:
|
||||||
|
report.add(
|
||||||
|
"lang",
|
||||||
|
"Name the page language with matching xml:lang and lang attributes "
|
||||||
|
"on <html>, so browsers and clients both know it.",
|
||||||
|
)
|
||||||
|
report.language = xml_lang or lang or ""
|
||||||
|
|
||||||
|
head = root.find(f"{{{XHTML}}}head")
|
||||||
|
if head is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
title = head.find(f"{{{XHTML}}}title")
|
||||||
|
if title is not None and title.text:
|
||||||
|
report.title = _clean(title.text)
|
||||||
|
|
||||||
|
markers = []
|
||||||
|
for meta in head.iterfind(f"{{{XHTML}}}meta"):
|
||||||
|
name = meta.get("name") or ""
|
||||||
|
if name not in ALLOWED_META:
|
||||||
|
report.add(
|
||||||
|
"meta-name",
|
||||||
|
f'The meta element named "{name}" isn\'t allowed. Section 3.3 '
|
||||||
|
"lists the ones a head may hold.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if name == MARKER:
|
||||||
|
markers.append(meta.get("content") or "")
|
||||||
|
elif name == "description":
|
||||||
|
report.description = _clean(meta.get("content") or "")
|
||||||
|
|
||||||
|
if len(markers) > 1:
|
||||||
|
report.add(
|
||||||
|
"marker",
|
||||||
|
'The head carries the <meta name="mews-profile" /> marker '
|
||||||
|
f"{len(markers)} times. Keep one.",
|
||||||
|
)
|
||||||
|
elif not markers:
|
||||||
|
report.add(
|
||||||
|
"marker",
|
||||||
|
'Add <meta name="mews-profile" content="0.1" /> to the head. '
|
||||||
|
"Clients use it to tell a Mews page apart.",
|
||||||
|
)
|
||||||
|
elif markers[0] != VERSION:
|
||||||
|
report.add(
|
||||||
|
"marker-version",
|
||||||
|
f'The marker says version "{markers[0]}". This validator checks '
|
||||||
|
f"version {VERSION}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
viewport = any(
|
||||||
|
meta.get("name") == "viewport" for meta in head.iterfind(f"{{{XHTML}}}meta")
|
||||||
|
)
|
||||||
|
if not viewport:
|
||||||
|
report.add(
|
||||||
|
"viewport",
|
||||||
|
'Add <meta name="viewport" content="width=device-width" /> so phones '
|
||||||
|
"show the page at a readable size.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_findings(
|
||||||
|
report: Report, head: etree._Element, host: str | None
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Check the head's links, returning the stylesheet href and if it's off-site."""
|
||||||
|
stylesheets: list[str] = []
|
||||||
|
for link in head.iterfind(f"{{{XHTML}}}link"):
|
||||||
|
rel = (link.get("rel") or "").lower()
|
||||||
|
href = link.get("href") or ""
|
||||||
|
if rel not in ALLOWED_RELS:
|
||||||
|
# Reported by the DTD check, which also names the three kinds.
|
||||||
|
continue
|
||||||
|
if rel == "stylesheet":
|
||||||
|
stylesheets.append(href)
|
||||||
|
elif rel == "alternate":
|
||||||
|
kind = (link.get("type") or "").split(";")[0].strip()
|
||||||
|
if kind != "application/atom+xml":
|
||||||
|
report.add(
|
||||||
|
"link-alternate-type",
|
||||||
|
'A feed link needs type="application/atom+xml".',
|
||||||
|
)
|
||||||
|
elif rel == "icon" and host and not _is_same_site(host, href):
|
||||||
|
report.add(
|
||||||
|
"icon-offsite",
|
||||||
|
"The icon is on another site. Host it on your own site, so "
|
||||||
|
"reading a page tells no one else about it.",
|
||||||
|
href,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(stylesheets) > 1:
|
||||||
|
report.add(
|
||||||
|
"link-stylesheet-count",
|
||||||
|
"Link the default stylesheet once. A Mews page has no other stylesheet.",
|
||||||
|
)
|
||||||
|
if not stylesheets:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-missing",
|
||||||
|
"Link your copy of mews-0.1.css, so readers without a Mews client "
|
||||||
|
"still get good presentation.",
|
||||||
|
)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
href = stylesheets[0]
|
||||||
|
if host and not _is_same_site(host, href):
|
||||||
|
if _absolute(href).rstrip("/") == CANONICAL_STYLESHEET:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-canonical",
|
||||||
|
"Copy mews-0.1.css to your own site and link that copy, so no "
|
||||||
|
"single server sees traffic across every Mews site.",
|
||||||
|
href,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-offsite",
|
||||||
|
"The stylesheet is on another site. A Mews page loads nothing "
|
||||||
|
"from other sites. Copy mews-0.1.css to your own site.",
|
||||||
|
href,
|
||||||
|
)
|
||||||
|
return href, True
|
||||||
|
return href, False
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(text: str) -> str:
|
||||||
|
"""Collapse whitespace and drop characters that could reshape a listing."""
|
||||||
|
# Bidirectional overrides are printable but can make a title read as a
|
||||||
|
# different domain, so they go too.
|
||||||
|
overrides = frozenset(
|
||||||
|
chr(code) for code in (*range(0x202A, 0x202F), *range(0x2066, 0x206A))
|
||||||
|
)
|
||||||
|
stripped = "".join(
|
||||||
|
character
|
||||||
|
for character in text
|
||||||
|
if character.isprintable() and character not in overrides
|
||||||
|
)
|
||||||
|
return " ".join(stripped.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_same_site(host: str, href: str) -> bool:
|
||||||
|
"""Say whether an href stays on the page's registered domain."""
|
||||||
|
other = urlsplit(href).hostname
|
||||||
|
return other is None or same_site(host, other)
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute(href: str) -> str:
|
||||||
|
"""Treat an href with no scheme as https, for comparing with a known URL."""
|
||||||
|
return href if "://" in href else "https://" + href.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _transport_findings(report: Report, response: Fetched) -> None:
|
||||||
|
"""Check the response headers against sections 7.1 to 7.4."""
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
report.add(
|
||||||
|
"cookie",
|
||||||
|
"The server sets a cookie on this page. A Mews page sets cookies "
|
||||||
|
"only for a form the reader submits.",
|
||||||
|
)
|
||||||
|
kind = (response.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||||
|
if kind not in (
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml+xml",
|
||||||
|
"application/vnd.wap.xhtml+xml",
|
||||||
|
):
|
||||||
|
report.add(
|
||||||
|
"content-type",
|
||||||
|
f'The server sends this page as "{kind or "nothing"}". Send it as '
|
||||||
|
"text/html, so a browser still shows a page with a small error.",
|
||||||
|
)
|
||||||
|
if not response.headers.get("last-modified") and not response.headers.get("etag"):
|
||||||
|
report.add(
|
||||||
|
"validators",
|
||||||
|
"Send a Last-Modified or ETag header, so clients and the directory "
|
||||||
|
"can check for changes cheaply.",
|
||||||
|
)
|
||||||
|
if urlsplit(response.url).scheme != "https" or response.scheme_downgraded:
|
||||||
|
report.add(
|
||||||
|
"https",
|
||||||
|
"Serve the page over HTTPS. You may serve plain HTTP in parallel "
|
||||||
|
"for old handsets.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_findings(
|
||||||
|
report: Report, root: etree._Element, url: str | None, fetcher: Fetcher | None
|
||||||
|
) -> int:
|
||||||
|
"""Check every image, returning the bytes the fetched ones took."""
|
||||||
|
host = urlsplit(url).hostname if url else None
|
||||||
|
images = list(root.iter(f"{{{XHTML}}}img"))
|
||||||
|
total = 0
|
||||||
|
fetched = 0
|
||||||
|
for image in images:
|
||||||
|
src = (image.get("src") or "").strip()
|
||||||
|
if src.lower().startswith("data:"):
|
||||||
|
report.add(
|
||||||
|
"image-data-uri",
|
||||||
|
"This image is built into the page as a data: URI. Save it as a "
|
||||||
|
"file on your own site and link it.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not src:
|
||||||
|
continue
|
||||||
|
target = urljoin(url, src) if url else src
|
||||||
|
if host and not _is_same_site(host, target):
|
||||||
|
report.add(
|
||||||
|
"image-offsite",
|
||||||
|
"This image is on another site. Host it on your own site, so "
|
||||||
|
"reading a page tells no one else about it.",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if fetcher is None or url is None:
|
||||||
|
continue
|
||||||
|
if fetched >= MAX_IMAGES:
|
||||||
|
continue
|
||||||
|
fetched += 1
|
||||||
|
total += _check_image(report, target, src, fetcher)
|
||||||
|
|
||||||
|
if fetcher is not None and len(images) > MAX_IMAGES:
|
||||||
|
report.add(
|
||||||
|
"images-not-checked",
|
||||||
|
f"This page has {len(images)} images and the checker read the first "
|
||||||
|
f"{MAX_IMAGES}. Check the rest yourself.",
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _check_image(report: Report, target: str, src: str, fetcher: Fetcher) -> int:
|
||||||
|
"""Fetch one image and check its format, size and metadata."""
|
||||||
|
try:
|
||||||
|
response = fetcher.get(target, cap=IMAGE_CAP)
|
||||||
|
except (UrlError, FetchError) as error:
|
||||||
|
report.add("image-unreachable", f"This image didn't load: {error}", src)
|
||||||
|
return 0
|
||||||
|
if response.status != OK:
|
||||||
|
report.add(
|
||||||
|
"image-unreachable",
|
||||||
|
f"This image didn't load (it returned {response.status}).",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
report.add("cookie", "The server sets a cookie on this image.", src)
|
||||||
|
|
||||||
|
body = response.body
|
||||||
|
if response.truncated:
|
||||||
|
report.add(
|
||||||
|
"image-size",
|
||||||
|
f"This image is over {IMAGE_CAP // 1024} KB. Keep images under "
|
||||||
|
"50 KB, so a page loads quickly on a slow connection.",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
elif len(body) > IMAGE_SHOULD:
|
||||||
|
report.add(
|
||||||
|
"image-size",
|
||||||
|
f"This image is {len(body) // 1024} KB. Keep images under 50 KB, so "
|
||||||
|
"a page loads quickly on a slow connection.",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not any(body.startswith(magic) for magic in IMAGE_MAGIC):
|
||||||
|
report.add(
|
||||||
|
"image-format",
|
||||||
|
"This image isn't a GIF, JPEG or PNG. Use one of those, so every "
|
||||||
|
"client and old handset can show it.",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
elif _has_metadata(body):
|
||||||
|
report.add(
|
||||||
|
"image-metadata",
|
||||||
|
"This image carries camera or location metadata. Strip it before "
|
||||||
|
"publishing.",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
return len(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_metadata(body: bytes) -> bool:
|
||||||
|
"""Say whether an image carries an Exif block."""
|
||||||
|
if body.startswith(b"\xff\xd8\xff"):
|
||||||
|
return b"Exif\x00\x00" in body[:4096]
|
||||||
|
if body.startswith(b"\x89PNG"):
|
||||||
|
return b"eXIf" in body[:4096]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stylesheet_findings(report: Report, href: str, url: str, fetcher: Fetcher) -> None:
|
||||||
|
"""Fetch the linked stylesheet and compare it with the default one."""
|
||||||
|
target = urljoin(url, href)
|
||||||
|
host = urlsplit(url).hostname or ""
|
||||||
|
try:
|
||||||
|
response = fetcher.get(target, cap=CSS_CAP)
|
||||||
|
except (UrlError, FetchError) as error:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-unreachable", f"The stylesheet didn't load: {error}", href
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if response.status != OK:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-unreachable",
|
||||||
|
f"The stylesheet didn't load (it returned {response.status}).",
|
||||||
|
href,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if "set-cookie" in response.headers:
|
||||||
|
report.add("cookie", "The server sets a cookie on the stylesheet.", href)
|
||||||
|
|
||||||
|
comparison = mews_css.compare(response.body.decode("utf-8", "replace"))
|
||||||
|
if comparison.has_import:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-import",
|
||||||
|
"Your stylesheet copy uses @import. Remove it: a Mews page loads "
|
||||||
|
"one stylesheet and nothing else.",
|
||||||
|
href,
|
||||||
|
)
|
||||||
|
if not comparison.equal:
|
||||||
|
report.add(
|
||||||
|
"stylesheet-modified",
|
||||||
|
"Your copy of mews-0.1.css has been changed, starting around line "
|
||||||
|
f"{comparison.diff_line}. Copy it again unchanged. You may add "
|
||||||
|
"@font-face rules that load fonts from your own site.",
|
||||||
|
href,
|
||||||
|
)
|
||||||
|
for font in comparison.font_urls:
|
||||||
|
if not _is_same_site(host, urljoin(target, font)):
|
||||||
|
report.add(
|
||||||
|
"font-offsite",
|
||||||
|
"An @font-face rule loads a font from another site. Host the "
|
||||||
|
"font file on your own site.",
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Entry points --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bytes(
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
url: str | None = None,
|
||||||
|
response: Fetched | None = None,
|
||||||
|
fetcher: Fetcher | None = None,
|
||||||
|
) -> Report:
|
||||||
|
"""Validate one page.
|
||||||
|
|
||||||
|
url is the address the page was fetched from, which the same-site rules in
|
||||||
|
4.2 and 7.4 need. Without a fetcher the checks that need the live page are
|
||||||
|
skipped and said to be skipped.
|
||||||
|
"""
|
||||||
|
report = Report(url=url)
|
||||||
|
report.size = len(data)
|
||||||
|
|
||||||
|
can_parse, expand_entities = _prologue(report, data)
|
||||||
|
if not can_parse:
|
||||||
|
return report
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = etree.fromstring(data, _parser(expand_entities=expand_entities))
|
||||||
|
except etree.XMLSyntaxError as error:
|
||||||
|
report.add(
|
||||||
|
"well-formed",
|
||||||
|
"This page isn't well-formed XML, so a Mews client can't read it "
|
||||||
|
f"({error.msg}).",
|
||||||
|
f"line {error.lineno}",
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
tree = etree.ElementTree(root)
|
||||||
|
|
||||||
|
_document_findings(report, root)
|
||||||
|
_dtd_findings(report, tree)
|
||||||
|
|
||||||
|
host = urlsplit(url).hostname if url else None
|
||||||
|
head = root.find(f"{{{XHTML}}}head")
|
||||||
|
stylesheet, offsite = (None, False)
|
||||||
|
if head is not None:
|
||||||
|
stylesheet, offsite = _link_findings(report, head, host)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
_transport_findings(report, response)
|
||||||
|
|
||||||
|
image_bytes = _image_findings(report, root, url, fetcher)
|
||||||
|
|
||||||
|
if stylesheet and not offsite and url and fetcher is not None:
|
||||||
|
_stylesheet_findings(report, stylesheet, url, fetcher)
|
||||||
|
|
||||||
|
if (response is not None and response.truncated) or report.size > SIZE_MUST:
|
||||||
|
report.add(
|
||||||
|
"size-must",
|
||||||
|
f"The page markup is over {SIZE_MUST // 1024} KB. Split it into "
|
||||||
|
"several pages.",
|
||||||
|
)
|
||||||
|
elif report.size > SIZE_SHOULD:
|
||||||
|
report.add(
|
||||||
|
"size-should",
|
||||||
|
f"The page markup is {report.size // 1024} KB. Keep it under "
|
||||||
|
"64 KB, so it loads quickly on a slow connection.",
|
||||||
|
)
|
||||||
|
if fetcher is not None and report.size + image_bytes > SIZE_TOTAL:
|
||||||
|
report.add(
|
||||||
|
"size-total",
|
||||||
|
"The page and its images come to more than 320 KB together. Use "
|
||||||
|
"fewer or smaller images.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if fetcher is None:
|
||||||
|
report.notes.append(
|
||||||
|
"The checks that need the live page were skipped: response headers "
|
||||||
|
"(7.1 to 7.4), image sizes and formats (4.2), and the stylesheet "
|
||||||
|
"copy (5.2)."
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file(path: str) -> Report:
|
||||||
|
"""Validate a page on disk."""
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
return validate_bytes(handle.read())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(
|
||||||
|
url: str, *, fetcher: Fetcher | None = None, allow_loopback: bool = False
|
||||||
|
) -> Report:
|
||||||
|
"""Fetch a page and validate it, along with its stylesheet and images."""
|
||||||
|
owned = fetcher is None
|
||||||
|
client = fetcher or Fetcher(allow_loopback=allow_loopback)
|
||||||
|
try:
|
||||||
|
response = client.get(normalise_url(url))
|
||||||
|
if response.status != OK:
|
||||||
|
report = Report(url=response.url)
|
||||||
|
report.add(
|
||||||
|
"well-formed",
|
||||||
|
f"We couldn't read that page (it returned {response.status}). "
|
||||||
|
"Check the address and try again.",
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
return validate_bytes(
|
||||||
|
response.body, url=response.url, response=response, fetcher=client
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if owned:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Check pages named on the command line, returning 1 if any doesn't conform."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Check a page against Mews Profile 0.1 (see doc/SPEC.md)."
|
||||||
|
)
|
||||||
|
parser.add_argument("target", nargs="+", help="a page address or a file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--local",
|
||||||
|
action="store_true",
|
||||||
|
help="also read addresses on this machine, for checking a site before "
|
||||||
|
"it is published",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--quiet", action="store_true", help="print nothing for pages that conform"
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
worst = 0
|
||||||
|
for target in args.target:
|
||||||
|
if "://" in target or not _looks_like_path(target):
|
||||||
|
try:
|
||||||
|
report = validate_url(target, allow_loopback=args.local)
|
||||||
|
except (UrlError, FetchError) as error:
|
||||||
|
print(f"{target} — {error}", file=sys.stderr)
|
||||||
|
worst = 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
report = validate_file(target)
|
||||||
|
|
||||||
|
if report.conforms and args.quiet:
|
||||||
|
continue
|
||||||
|
worst = max(worst, 0 if report.conforms else 1)
|
||||||
|
verdict = "conforms" if report.conforms else "doesn't conform"
|
||||||
|
print(f"{target} — {verdict}")
|
||||||
|
for finding in report.failures:
|
||||||
|
print(f" {finding}")
|
||||||
|
if report.warnings:
|
||||||
|
print(" Worth fixing:")
|
||||||
|
for finding in report.warnings:
|
||||||
|
print(f" {finding}")
|
||||||
|
for note in report.notes:
|
||||||
|
print(f" Note: {note}")
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_path(target: str) -> bool:
|
||||||
|
"""Say whether a bare argument names a file rather than a site."""
|
||||||
|
return Path(target).exists()
|
||||||
16
mewslint.py
Normal file
16
mewslint.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Check a page against Mews Profile 0.1.
|
||||||
|
|
||||||
|
Run with: uv run mewslint.py https://example.com/
|
||||||
|
or over files: uv run mewslint.py sample/sample.html
|
||||||
|
|
||||||
|
The checks and their spec sections live in mews/lint.py. Dependencies come from
|
||||||
|
pyproject.toml, so there is no second list here to drift out of step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mews.lint import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
104
pyproject.toml
Normal file
104
pyproject.toml
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
[project]
|
||||||
|
name = "mews"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Page validator and site directory for the Mews Profile"
|
||||||
|
authors = [{name = "Mews Profile developers"}]
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"lxml>=5.0",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"publicsuffixlist>=0.10",
|
||||||
|
"waitress>=3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mewsd = "mews.cli:main"
|
||||||
|
mewslint = "mews.lint:main"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"ruff>=0.15",
|
||||||
|
# md2mews.py declares this in its own script header; tests import the
|
||||||
|
# script to check its page emitter still matches mews.pages.
|
||||||
|
"mistune>=3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
# Both predate this configuration and run standalone from their own dependency
|
||||||
|
# headers, so they are left as they are rather than reformatted.
|
||||||
|
extend-exclude = ["md2mews.py", "dtd/check_dtd.py"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"BLE", # flake8-blind-except
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"D", # pydocstyle
|
||||||
|
"E", # pycodestyle
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"ICN", # flake8-import-conventions
|
||||||
|
"ISC", # flake8-implicit-str-concat
|
||||||
|
"PGH", # pygrep-hooks
|
||||||
|
"PL", # pylint
|
||||||
|
"RET", # flake8-return
|
||||||
|
"RUF", # ruff-specific rules
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"T10", # flake8-debugger
|
||||||
|
"T20", # flake8-print
|
||||||
|
"TID", # flake8-tidy-imports
|
||||||
|
"TRY", # tryceratops
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"W", # pycodestyle
|
||||||
|
"YTT", # flake8-2020
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
"D100", # missing docstring in public module
|
||||||
|
"D104", # missing docstring in public package
|
||||||
|
"D203", # blank line before class docstring
|
||||||
|
"D213", # multi-line docstring summary should start at the second line
|
||||||
|
"ISC001", # disabled so `ruff format` works without warning
|
||||||
|
"D105", # missing docstring in magic method
|
||||||
|
"D107", # missing docstring in __init__
|
||||||
|
"PLR0911", # too many return statements
|
||||||
|
"PLR0912", # too many branches
|
||||||
|
"PLR0913", # too many arguments
|
||||||
|
"PLR0915", # too many statements
|
||||||
|
"TRY003", # exception messages are the text shown to the author
|
||||||
|
"PLR0917", # too many positional arguments: page() mirrors md2mews.page()
|
||||||
|
# The messages authors read are wrapped across lines inside two-element
|
||||||
|
# tuples, where a missing comma would raise rather than merge two items.
|
||||||
|
"ISC004",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
combine-as-imports = true
|
||||||
|
force-sort-within-sections = true
|
||||||
|
known-first-party = ["mews"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = [
|
||||||
|
"D103", # a test name says what it tests
|
||||||
|
"PLC0415", # imports inside a test keep it self-contained
|
||||||
|
"PLE2502", # fixtures deliberately carry bidi overrides
|
||||||
|
"PLR2004", # expected values belong in the assertion
|
||||||
|
"D401", # a fixture docstring names what the fixture is
|
||||||
|
]
|
||||||
|
# These print their results: they are the command line interface.
|
||||||
|
"mews/cli.py" = ["T201"]
|
||||||
|
"mews/lint.py" = ["T201"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.8"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.uv.build-backend]
|
||||||
|
module-root = ""
|
||||||
|
module-name = "mews"
|
||||||
336
uv.lock
generated
Normal file
336
uv.lock
generated
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml"
|
||||||
|
version = "6.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mews"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "publicsuffixlist" },
|
||||||
|
{ name = "waitress" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "mistune" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "lxml", specifier = ">=5.0" },
|
||||||
|
{ name = "publicsuffixlist", specifier = ">=0.10" },
|
||||||
|
{ name = "waitress", specifier = ">=3.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "mistune", specifier = ">=3.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.15" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mistune"
|
||||||
|
version = "3.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "publicsuffixlist"
|
||||||
|
version = "1.1.0.20261010"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/4f/54b0f7bf18b0ec89742d7d2657f04feaadd27f7ca99800296c526daf1a84/publicsuffixlist-1.1.0.20261010.tar.gz", hash = "sha256:1ebd4c9edade8a6c9bd01995a6f9e67220948dca5879b1fd48a565cb429d9aeb", size = 106328, upload-time = "2026-10-10T08:49:17.176Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/49/2c38fc44251f420d9aba4de40be84facb1d82a8a419c8cb71b5ebfa32055/publicsuffixlist-1.1.0.20261010-py3-none-any.whl", hash = "sha256:c92763e6b3982c850e9047eb95aa864753ec533f761b18bae4d04226d5e12f1d", size = 109052, upload-time = "2026-10-10T08:49:15.955Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "waitress"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue