pyIntradel 0.0.6__tar.gz → 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyintradel-0.0.6 → pyintradel-0.1.0}/PKG-INFO +43 -12
- {pyintradel-0.0.6 → pyintradel-0.1.0}/README.md +38 -3
- pyintradel-0.1.0/pyintradel/api/__init__.py +56 -0
- pyintradel-0.1.0/pyintradel/api/exceptions.py +31 -0
- {pyintradel-0.0.6 → pyintradel-0.1.0}/pyintradel/api/parser.py +15 -7
- pyintradel-0.1.0/pyintradel/main.py +36 -0
- {pyintradel-0.0.6 → pyintradel-0.1.0}/pyproject.toml +16 -9
- pyintradel-0.1.0/tests/api_get_data_test.py +168 -0
- pyintradel-0.1.0/tests/api_test.py +20 -0
- pyintradel-0.1.0/tests/fixtures/correct_response.html +831 -0
- pyintradel-0.1.0/tests/fixtures/incorrect_login.html +476 -0
- pyintradel-0.1.0/tests/mock.py +12 -0
- pyintradel-0.1.0/uv.lock +914 -0
- pyintradel-0.0.6/.github/check_version.sh +0 -13
- pyintradel-0.0.6/.github/workflows/build.yml +0 -50
- pyintradel-0.0.6/.github/workflows/publish-pypi.yml +0 -37
- pyintradel-0.0.6/pyintradel/api/__init__.py +0 -30
- pyintradel-0.0.6/pyintradel/api/towns.py +0 -75
- pyintradel-0.0.6/pyintradel/main.py +0 -24
- pyintradel-0.0.6/tests/api_test.py +0 -13
- pyintradel-0.0.6/tests/mock.py +0 -1216
- {pyintradel-0.0.6 → pyintradel-0.1.0}/.gitignore +0 -0
- {pyintradel-0.0.6 → pyintradel-0.1.0}/LICENSE +0 -0
- {pyintradel-0.0.6 → pyintradel-0.1.0}/pyintradel/__init__.py +0 -0
- {pyintradel-0.0.6 → pyintradel-0.1.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pyIntradel
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Python interface for Intradel
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomasgermain/pyintradel
|
|
6
6
|
Author-email: Thomas Germain <12560542+thomasgermain@users.noreply.github.com>
|
|
@@ -9,17 +9,13 @@ License-File: LICENSE
|
|
|
9
9
|
Classifier: Development Status :: 4 - Beta
|
|
10
10
|
Classifier: License :: OSI Approved :: MIT License
|
|
11
11
|
Classifier: Programming Language :: Python
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
15
|
Classifier: Topic :: Home Automation
|
|
15
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.13
|
|
16
17
|
Requires-Dist: aiohttp<4.0,>=3.11
|
|
17
18
|
Requires-Dist: beautifulsoup4<5.0,>=4.15
|
|
18
|
-
Provides-Extra: dev
|
|
19
|
-
Requires-Dist: build>=1.5; extra == 'dev'
|
|
20
|
-
Requires-Dist: mypy>=2.1; extra == 'dev'
|
|
21
|
-
Requires-Dist: pytest>=9.0; extra == 'dev'
|
|
22
|
-
Requires-Dist: ruff>=0.15; extra == 'dev'
|
|
23
19
|
Description-Content-Type: text/markdown
|
|
24
20
|
|
|
25
21
|
# pyIntradel
|
|
@@ -104,7 +100,15 @@ Here is an example of json:
|
|
|
104
100
|
|
|
105
101
|
## Usage
|
|
106
102
|
|
|
107
|
-
The
|
|
103
|
+
The intradel.be login form is protected by an invisible reCAPTCHA, so this library
|
|
104
|
+
cannot log in by itself. Authenticate once in a real browser, then pass the resulting
|
|
105
|
+
session cookie (the raw `Cookie` header, e.g. `PHPSESSID=...`).
|
|
106
|
+
|
|
107
|
+
Errors raised by `api.get_data`, all subclasses of `api.IntradelError` (itself a `ValueError`):
|
|
108
|
+
- `api.AuthenticationError`: the cookie has expired or is invalid; log in again in the
|
|
109
|
+
browser and use the new cookie.
|
|
110
|
+
- `api.ApiError`: unexpected HTTP status, available as `.status`.
|
|
111
|
+
- `api.ParseError`: the page structure is not the expected one, likely after a website change.
|
|
108
112
|
|
|
109
113
|
### Python module
|
|
110
114
|
|
|
@@ -113,13 +117,40 @@ import aiohttp
|
|
|
113
117
|
from pyintradel import api
|
|
114
118
|
|
|
115
119
|
async with aiohttp.ClientSession() as sess:
|
|
116
|
-
await api.get_data(sess,
|
|
120
|
+
await api.get_data(sess, cookie)
|
|
117
121
|
```
|
|
118
122
|
|
|
119
123
|
### Command line
|
|
120
124
|
```bash
|
|
121
|
-
python3 -m pyintradel.main
|
|
125
|
+
python3 -m pyintradel.main --cookie "PHPSESSID=..."
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
The project uses [uv](https://docs.astral.sh/uv/):
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync # create .venv with the dev tools from uv.lock
|
|
134
|
+
uv run pytest
|
|
135
|
+
uv run ruff check
|
|
136
|
+
uv run ruff format
|
|
137
|
+
uv run mypy
|
|
138
|
+
uv build # sdist and wheel in dist/
|
|
122
139
|
```
|
|
123
140
|
|
|
141
|
+
Install the git pre-commit hook once per clone; it runs ruff (lint with autofix, format) and
|
|
142
|
+
checks that `uv.lock` matches `pyproject.toml` before each commit:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
uv tool install pre-commit
|
|
146
|
+
pre-commit install
|
|
147
|
+
pre-commit run --all-files # optional: run the hooks on the whole repository
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
CI runs the same checks plus mypy and the tests, so skipping the hook only delays the feedback.
|
|
151
|
+
|
|
152
|
+
To release, publish a GitHub release whose tag is the new version (e.g. `0.1.0`): the
|
|
153
|
+
version is derived from the tag, and CI publishes to PyPI once the checks pass.
|
|
154
|
+
|
|
124
155
|
---
|
|
125
156
|
<a href="https://www.buymeacoffee.com/tgermain" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: auto !important;width: auto !important;" ></a>
|
|
@@ -80,7 +80,15 @@ Here is an example of json:
|
|
|
80
80
|
|
|
81
81
|
## Usage
|
|
82
82
|
|
|
83
|
-
The
|
|
83
|
+
The intradel.be login form is protected by an invisible reCAPTCHA, so this library
|
|
84
|
+
cannot log in by itself. Authenticate once in a real browser, then pass the resulting
|
|
85
|
+
session cookie (the raw `Cookie` header, e.g. `PHPSESSID=...`).
|
|
86
|
+
|
|
87
|
+
Errors raised by `api.get_data`, all subclasses of `api.IntradelError` (itself a `ValueError`):
|
|
88
|
+
- `api.AuthenticationError`: the cookie has expired or is invalid; log in again in the
|
|
89
|
+
browser and use the new cookie.
|
|
90
|
+
- `api.ApiError`: unexpected HTTP status, available as `.status`.
|
|
91
|
+
- `api.ParseError`: the page structure is not the expected one, likely after a website change.
|
|
84
92
|
|
|
85
93
|
### Python module
|
|
86
94
|
|
|
@@ -89,13 +97,40 @@ import aiohttp
|
|
|
89
97
|
from pyintradel import api
|
|
90
98
|
|
|
91
99
|
async with aiohttp.ClientSession() as sess:
|
|
92
|
-
await api.get_data(sess,
|
|
100
|
+
await api.get_data(sess, cookie)
|
|
93
101
|
```
|
|
94
102
|
|
|
95
103
|
### Command line
|
|
96
104
|
```bash
|
|
97
|
-
python3 -m pyintradel.main
|
|
105
|
+
python3 -m pyintradel.main --cookie "PHPSESSID=..."
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
The project uses [uv](https://docs.astral.sh/uv/):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv sync # create .venv with the dev tools from uv.lock
|
|
114
|
+
uv run pytest
|
|
115
|
+
uv run ruff check
|
|
116
|
+
uv run ruff format
|
|
117
|
+
uv run mypy
|
|
118
|
+
uv build # sdist and wheel in dist/
|
|
98
119
|
```
|
|
99
120
|
|
|
121
|
+
Install the git pre-commit hook once per clone; it runs ruff (lint with autofix, format) and
|
|
122
|
+
checks that `uv.lock` matches `pyproject.toml` before each commit:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv tool install pre-commit
|
|
126
|
+
pre-commit install
|
|
127
|
+
pre-commit run --all-files # optional: run the hooks on the whole repository
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
CI runs the same checks plus mypy and the tests, so skipping the hook only delays the feedback.
|
|
131
|
+
|
|
132
|
+
To release, publish a GitHub release whose tag is the new version (e.g. `0.1.0`): the
|
|
133
|
+
version is derived from the tag, and CI publishes to PyPI once the checks pass.
|
|
134
|
+
|
|
100
135
|
---
|
|
101
136
|
<a href="https://www.buymeacoffee.com/tgermain" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: auto !important;width: auto !important;" ></a>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""pyIntradel"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from . import parser
|
|
10
|
+
from .exceptions import ApiError, AuthenticationError, IntradelError, ParseError
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
_DATA_URL = "https://www.intradel.be/particulier/data.php"
|
|
14
|
+
# Registrable domain, so cookies set for the host or for the whole domain are both covered.
|
|
15
|
+
_COOKIE_DOMAIN = "intradel.be"
|
|
16
|
+
# Case-insensitive: HTTP/2 dev tools show the header name lowercased.
|
|
17
|
+
_COOKIE_HEADER_PREFIX = re.compile(r"^\s*cookie\s*:", re.IGNORECASE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_data(session: aiohttp.ClientSession, cookie: str) -> list[Any]:
|
|
21
|
+
"""Fetch waste collection data.
|
|
22
|
+
|
|
23
|
+
`cookie` is the raw `Cookie` header captured from an already logged-in browser session.
|
|
24
|
+
The website gates its login form behind an invisible reCAPTCHA, so the library cannot
|
|
25
|
+
log in by itself.
|
|
26
|
+
|
|
27
|
+
Intradel cookies stored in the session's cookie jar are cleared before the request.
|
|
28
|
+
"""
|
|
29
|
+
cookie = _normalize_cookie(cookie)
|
|
30
|
+
if not cookie:
|
|
31
|
+
raise AuthenticationError("No cookie found in the provided value")
|
|
32
|
+
|
|
33
|
+
_LOGGER.info("Will query data using a session cookie")
|
|
34
|
+
# Intradel sets an anonymous PHPSESSID on any request without a session, and aiohttp lets
|
|
35
|
+
# jar cookies override a hand-written Cookie header: a shared session would otherwise send
|
|
36
|
+
# that anonymous session instead of ours. The header itself is sent exactly as pasted.
|
|
37
|
+
session.cookie_jar.clear_domain(_COOKIE_DOMAIN)
|
|
38
|
+
async with session.get(_DATA_URL, headers={"Cookie": cookie}, allow_redirects=False) as resp:
|
|
39
|
+
# Intradel redirects to its login page when the session is not valid.
|
|
40
|
+
if resp.status == 302:
|
|
41
|
+
raise AuthenticationError()
|
|
42
|
+
if resp.status != 200:
|
|
43
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
44
|
+
# Lenient decoding: an error page in an odd charset must not mask the ApiError.
|
|
45
|
+
body = await resp.text(errors="replace")
|
|
46
|
+
_LOGGER.debug("HTTP %s body: %s", resp.status, body)
|
|
47
|
+
raise ApiError(resp.status)
|
|
48
|
+
return parser.parse(await resp.text())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_cookie(cookie: str) -> str:
|
|
52
|
+
"""Accept the cookie as copied from browser dev tools, e.g. "Cookie: PHPSESSID=...\\n"."""
|
|
53
|
+
return _COOKIE_HEADER_PREFIX.sub("", cookie).strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["ApiError", "AuthenticationError", "IntradelError", "ParseError", "get_data"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class IntradelError(ValueError):
|
|
2
|
+
"""Base class for all errors raised by this library.
|
|
3
|
+
|
|
4
|
+
Subclasses ValueError so callers catching the previous generic error keep working.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(IntradelError):
|
|
9
|
+
"""Intradel refused the session cookie and sent its login page instead of the data."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str = "Session cookie rejected by intradel, it is probably expired or invalid",
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(IntradelError):
|
|
19
|
+
"""Intradel answered with an unexpected HTTP status."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status: int) -> None:
|
|
22
|
+
# args must hold the constructor input, so copy/pickle rebuild the same error.
|
|
23
|
+
super().__init__(status)
|
|
24
|
+
self.status = status
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return f"HTTP {self.status} from intradel"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ParseError(IntradelError):
|
|
31
|
+
"""The page does not have the expected structure, likely after a website change."""
|
|
@@ -2,6 +2,10 @@ from typing import Any
|
|
|
2
2
|
|
|
3
3
|
from bs4 import BeautifulSoup, Tag
|
|
4
4
|
|
|
5
|
+
from .exceptions import AuthenticationError, ParseError
|
|
6
|
+
|
|
7
|
+
_UNEXPECTED_STRUCTURE = "Unexpected response structure from intradel"
|
|
8
|
+
|
|
5
9
|
|
|
6
10
|
def parse(response: str) -> list[Any]:
|
|
7
11
|
"""Parse response from intradel"""
|
|
@@ -9,16 +13,20 @@ def parse(response: str) -> list[Any]:
|
|
|
9
13
|
soup = BeautifulSoup(response, features="html.parser")
|
|
10
14
|
|
|
11
15
|
if soup.select_one('[name="pLogin"]') is not None:
|
|
12
|
-
raise
|
|
16
|
+
raise AuthenticationError()
|
|
13
17
|
|
|
14
18
|
for data in soup.select(".grid .row .post__content"):
|
|
15
19
|
result: dict[str, Any] = {}
|
|
16
20
|
if data.find("h3") is not None:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
# The helpers index into the scraped markup; a website change surfaces as IndexError.
|
|
22
|
+
try:
|
|
23
|
+
name = _name(data)
|
|
24
|
+
start_date = _start_date(data)
|
|
25
|
+
chip_id = _chip_id(data) or name
|
|
26
|
+
details = _details(data)
|
|
27
|
+
total = _total(data) or str(len(details))
|
|
28
|
+
except IndexError as err:
|
|
29
|
+
raise ParseError(_UNEXPECTED_STRUCTURE) from err
|
|
22
30
|
|
|
23
31
|
result.update({"name": name})
|
|
24
32
|
result.update({"start_date": start_date})
|
|
@@ -33,7 +41,7 @@ def parse(response: str) -> list[Any]:
|
|
|
33
41
|
def _require_tag(node: object) -> Tag:
|
|
34
42
|
"""Narrow a bs4 lookup result to a Tag, failing loudly on unexpected markup."""
|
|
35
43
|
if not isinstance(node, Tag):
|
|
36
|
-
raise
|
|
44
|
+
raise ParseError(_UNEXPECTED_STRUCTURE)
|
|
37
45
|
return node
|
|
38
46
|
|
|
39
47
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from pyintradel import api
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _run(cookie: str) -> None:
|
|
12
|
+
async with aiohttp.ClientSession() as sess:
|
|
13
|
+
data = await api.get_data(sess, cookie)
|
|
14
|
+
json.dump(data, sys.stdout, indent=2)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="pyintradel")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--cookie",
|
|
21
|
+
required=True,
|
|
22
|
+
help="Session cookie captured from an already logged-in browser",
|
|
23
|
+
)
|
|
24
|
+
return parser.parse_args(argv)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> None:
|
|
28
|
+
args = _parse_args(sys.argv[1:])
|
|
29
|
+
try:
|
|
30
|
+
asyncio.run(_run(args.cookie))
|
|
31
|
+
except api.IntradelError as err:
|
|
32
|
+
sys.exit(f"pyintradel: {err}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyIntradel"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "Python interface for Intradel"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
|
-
requires-python = ">=3.
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
12
|
authors = [
|
|
13
13
|
{ name = "Thomas Germain", email = "12560542+thomasgermain@users.noreply.github.com" },
|
|
14
14
|
]
|
|
@@ -20,33 +20,39 @@ classifiers = [
|
|
|
20
20
|
"License :: OSI Approved :: MIT License",
|
|
21
21
|
"Development Status :: 4 - Beta",
|
|
22
22
|
"Programming Language :: Python",
|
|
23
|
-
"Programming Language :: Python :: 3.12",
|
|
24
23
|
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Programming Language :: Python :: 3.15",
|
|
25
26
|
"Topic :: Home Automation",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.urls]
|
|
29
30
|
Homepage = "https://github.com/thomasgermain/pyintradel"
|
|
30
31
|
|
|
31
|
-
[
|
|
32
|
+
[dependency-groups]
|
|
32
33
|
dev = [
|
|
33
34
|
"ruff>=0.15",
|
|
34
35
|
"mypy>=2.1",
|
|
35
36
|
"pytest>=9.0",
|
|
36
|
-
"build>=1.5",
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
40
40
|
pyintradel = "pyintradel.main:main"
|
|
41
41
|
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
# The version comes from the release tag (e.g. "0.1.0"), never from this file.
|
|
44
|
+
source = "vcs"
|
|
45
|
+
|
|
42
46
|
[tool.hatch.build.targets.wheel]
|
|
43
47
|
packages = ["pyintradel"]
|
|
44
48
|
|
|
49
|
+
# Explicit list, so local untracked files (editor or tool settings) never end up in the sdist.
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["pyintradel", "tests", "README.md", "LICENSE", "uv.lock"]
|
|
52
|
+
|
|
45
53
|
[tool.ruff]
|
|
46
54
|
line-length = 100
|
|
47
|
-
target-version = "
|
|
48
|
-
# Large scraped HTML fixture, kept verbatim for parser tests.
|
|
49
|
-
extend-exclude = ["tests/mock.py"]
|
|
55
|
+
target-version = "py313"
|
|
50
56
|
|
|
51
57
|
[tool.ruff.lint]
|
|
52
58
|
select = [
|
|
@@ -61,6 +67,7 @@ select = [
|
|
|
61
67
|
]
|
|
62
68
|
|
|
63
69
|
[tool.mypy]
|
|
70
|
+
files = ["pyintradel", "tests"]
|
|
64
71
|
strict = true
|
|
65
72
|
ignore_missing_imports = true
|
|
66
73
|
show_error_context = true
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
import unittest
|
|
3
|
+
from typing import Any
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from aiohttp import web
|
|
8
|
+
from aiohttp.test_utils import TestServer
|
|
9
|
+
from yarl import URL
|
|
10
|
+
|
|
11
|
+
from pyintradel import api
|
|
12
|
+
from tests.mock import CORRECT_RESPONSE, INCORRECT_LOGIN
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeResponse:
|
|
16
|
+
def __init__(self, status: int, body: str | bytes) -> None:
|
|
17
|
+
self.status = status
|
|
18
|
+
self._body = body.encode() if isinstance(body, str) else body
|
|
19
|
+
|
|
20
|
+
async def text(self, errors: str = "strict") -> str:
|
|
21
|
+
return self._body.decode("utf-8", errors)
|
|
22
|
+
|
|
23
|
+
async def __aenter__(self) -> "_FakeResponse":
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
async def __aexit__(self, *exc: Any) -> bool:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeCookieJar:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.cleared_domains: list[str] = []
|
|
33
|
+
|
|
34
|
+
def clear_domain(self, domain: str) -> None:
|
|
35
|
+
self.cleared_domains.append(domain)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _FakeSession:
|
|
39
|
+
def __init__(self, response: _FakeResponse) -> None:
|
|
40
|
+
self._response = response
|
|
41
|
+
self.cookie_jar = _FakeCookieJar()
|
|
42
|
+
self.get_calls: list[tuple[str, dict[str, str]]] = []
|
|
43
|
+
self.allow_redirects: list[bool] = []
|
|
44
|
+
|
|
45
|
+
def get(self, url: str, headers: dict[str, str], allow_redirects: bool) -> _FakeResponse:
|
|
46
|
+
self.get_calls.append((url, headers))
|
|
47
|
+
self.allow_redirects.append(allow_redirects)
|
|
48
|
+
return self._response
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GetDataTest(unittest.IsolatedAsyncioTestCase):
|
|
52
|
+
async def test_cookie_is_sent_after_clearing_intradel_cookies(self) -> None:
|
|
53
|
+
session = _FakeSession(_FakeResponse(200, CORRECT_RESPONSE))
|
|
54
|
+
result = await api.get_data(session, "PHPSESSID=abc") # type: ignore[arg-type]
|
|
55
|
+
|
|
56
|
+
self.assertEqual(len(result), 3)
|
|
57
|
+
self.assertEqual(session.get_calls, [(api._DATA_URL, {"Cookie": "PHPSESSID=abc"})])
|
|
58
|
+
self.assertEqual(session.cookie_jar.cleared_domains, ["intradel.be"])
|
|
59
|
+
|
|
60
|
+
async def test_full_browser_header_is_sent_unchanged(self) -> None:
|
|
61
|
+
# Values a strict cookie parser rejects or rewrites: JSON, spaces, bare flag, attribute
|
|
62
|
+
# names used as cookie names, commas.
|
|
63
|
+
raw = (
|
|
64
|
+
'consent={"a":1}; PHPSESSID=abc; OptanonConsent=datestamp=Thu Oct 24 2026 (CEST); '
|
|
65
|
+
"flag; path=/; other=a,b"
|
|
66
|
+
)
|
|
67
|
+
session = _FakeSession(_FakeResponse(200, CORRECT_RESPONSE))
|
|
68
|
+
await api.get_data(session, raw) # type: ignore[arg-type]
|
|
69
|
+
|
|
70
|
+
_, headers = session.get_calls[0]
|
|
71
|
+
self.assertEqual(headers, {"Cookie": raw})
|
|
72
|
+
|
|
73
|
+
async def test_empty_cookie_raises_before_any_request(self) -> None:
|
|
74
|
+
for raw in ("", " \n", "Cookie:", " cookie : \r\n"):
|
|
75
|
+
session = _FakeSession(_FakeResponse(200, CORRECT_RESPONSE))
|
|
76
|
+
with self.assertRaisesRegex(api.AuthenticationError, "No cookie found", msg=raw):
|
|
77
|
+
await api.get_data(session, raw) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
self.assertEqual(session.get_calls, [], raw)
|
|
80
|
+
|
|
81
|
+
async def test_redirect_to_login_raises_authentication_error(self) -> None:
|
|
82
|
+
session = _FakeSession(_FakeResponse(302, ""))
|
|
83
|
+
with self.assertRaisesRegex(api.AuthenticationError, "cookie"):
|
|
84
|
+
await api.get_data(session, "PHPSESSID=expired") # type: ignore[arg-type]
|
|
85
|
+
|
|
86
|
+
# Following the redirect would download and parse the whole login page.
|
|
87
|
+
self.assertEqual(session.allow_redirects, [False])
|
|
88
|
+
|
|
89
|
+
async def test_login_page_served_directly_raises_authentication_error(self) -> None:
|
|
90
|
+
session = _FakeSession(_FakeResponse(200, INCORRECT_LOGIN))
|
|
91
|
+
with self.assertRaisesRegex(api.AuthenticationError, "cookie"):
|
|
92
|
+
await api.get_data(session, "PHPSESSID=expired") # type: ignore[arg-type]
|
|
93
|
+
|
|
94
|
+
async def test_http_error_raises_api_error_without_body(self) -> None:
|
|
95
|
+
session = _FakeSession(_FakeResponse(503, "<html>" + "x" * 10_000 + "</html>"))
|
|
96
|
+
with self.assertRaises(api.ApiError) as ctx:
|
|
97
|
+
await api.get_data(session, "PHPSESSID=abc") # type: ignore[arg-type]
|
|
98
|
+
|
|
99
|
+
self.assertEqual(ctx.exception.status, 503)
|
|
100
|
+
self.assertEqual(str(ctx.exception), "HTTP 503 from intradel")
|
|
101
|
+
|
|
102
|
+
async def test_undecodable_error_body_still_raises_api_error(self) -> None:
|
|
103
|
+
session = _FakeSession(_FakeResponse(502, b"\xff\xfe not utf-8"))
|
|
104
|
+
with self.assertLogs("pyintradel.api", "DEBUG"), self.assertRaises(api.ApiError):
|
|
105
|
+
await api.get_data(session, "PHPSESSID=abc") # type: ignore[arg-type]
|
|
106
|
+
|
|
107
|
+
def test_api_error_survives_pickle(self) -> None:
|
|
108
|
+
err = pickle.loads(pickle.dumps(api.ApiError(503)))
|
|
109
|
+
|
|
110
|
+
self.assertEqual(err.status, 503)
|
|
111
|
+
self.assertEqual(str(err), "HTTP 503 from intradel")
|
|
112
|
+
|
|
113
|
+
async def test_cookie_copied_from_dev_tools_is_normalized(self) -> None:
|
|
114
|
+
for raw in ("Cookie: PHPSESSID=abc", "cookie:PHPSESSID=abc\n", " PHPSESSID=abc \r\n"):
|
|
115
|
+
session = _FakeSession(_FakeResponse(200, CORRECT_RESPONSE))
|
|
116
|
+
await api.get_data(session, raw) # type: ignore[arg-type]
|
|
117
|
+
|
|
118
|
+
_, headers = session.get_calls[0]
|
|
119
|
+
self.assertEqual(headers, {"Cookie": "PHPSESSID=abc"}, raw)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SharedSessionTest(unittest.IsolatedAsyncioTestCase):
|
|
123
|
+
"""Real aiohttp client against a local server mimicking intradel's cookie behaviour."""
|
|
124
|
+
|
|
125
|
+
async def asyncSetUp(self) -> None:
|
|
126
|
+
self.received_cookie_headers: list[str] = []
|
|
127
|
+
|
|
128
|
+
async def data(request: web.Request) -> web.Response:
|
|
129
|
+
header = request.headers.get("Cookie", "")
|
|
130
|
+
self.received_cookie_headers.append(header)
|
|
131
|
+
# Raw split, like PHP: a strict parser would choke on some real browser cookies.
|
|
132
|
+
if "PHPSESSID=valid" in [part.strip() for part in header.split(";")]:
|
|
133
|
+
return web.Response(text=CORRECT_RESPONSE, content_type="text/html")
|
|
134
|
+
# Like intradel: no valid session, so start an anonymous one and redirect to login.
|
|
135
|
+
response = web.Response(status=302, headers={"Location": "index.php"})
|
|
136
|
+
response.set_cookie("PHPSESSID", "anonymous")
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
app = web.Application()
|
|
140
|
+
app.router.add_get("/data.php", data)
|
|
141
|
+
self.server = TestServer(app)
|
|
142
|
+
await self.server.start_server()
|
|
143
|
+
self.addAsyncCleanup(self.server.close)
|
|
144
|
+
|
|
145
|
+
async def test_cookie_from_earlier_anonymous_request_does_not_override_ours(self) -> None:
|
|
146
|
+
url = str(self.server.make_url("/data.php"))
|
|
147
|
+
raw = 'consent={"a":1}; PHPSESSID=valid; flag'
|
|
148
|
+
# unsafe=True: the default jar ignores cookies from IP addresses such as 127.0.0.1.
|
|
149
|
+
async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session:
|
|
150
|
+
session.cookie_jar.update_cookies({"other": "1"}, URL("https://example.com/"))
|
|
151
|
+
# Any request without our cookie stores the server's anonymous session in the jar.
|
|
152
|
+
async with session.get(url, allow_redirects=False) as resp:
|
|
153
|
+
self.assertEqual(resp.status, 302)
|
|
154
|
+
self.assertIn("PHPSESSID", [c.key for c in session.cookie_jar])
|
|
155
|
+
|
|
156
|
+
with (
|
|
157
|
+
mock.patch.object(api, "_DATA_URL", url),
|
|
158
|
+
mock.patch.object(api, "_COOKIE_DOMAIN", self.server.host),
|
|
159
|
+
):
|
|
160
|
+
result = await api.get_data(session, raw)
|
|
161
|
+
|
|
162
|
+
remaining = [c.key for c in session.cookie_jar]
|
|
163
|
+
|
|
164
|
+
self.assertEqual(len(result), 3)
|
|
165
|
+
# Sent byte for byte, with nothing from the jar mixed in.
|
|
166
|
+
self.assertEqual(self.received_cookie_headers[-1], raw)
|
|
167
|
+
# Cookies of other sites are left alone.
|
|
168
|
+
self.assertEqual(remaining, ["other"])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from pyintradel.api.exceptions import AuthenticationError, ParseError
|
|
4
|
+
from pyintradel.api.parser import parse
|
|
5
|
+
from tests.mock import CORRECT_RESPONSE, INCORRECT_LOGIN
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParsingTest(unittest.TestCase):
|
|
9
|
+
def test_parsing_correct(self) -> None:
|
|
10
|
+
result = parse(CORRECT_RESPONSE)
|
|
11
|
+
self.assertEqual(len(result), 3)
|
|
12
|
+
|
|
13
|
+
def test_parsing_login_error(self) -> None:
|
|
14
|
+
self.assertRaises(AuthenticationError, parse, INCORRECT_LOGIN)
|
|
15
|
+
|
|
16
|
+
def test_parsing_unexpected_structure(self) -> None:
|
|
17
|
+
# A bin block whose details are missing, as after a website redesign.
|
|
18
|
+
html = '<div class="grid"><div class="row"><div class="post__content">'
|
|
19
|
+
html += "<h3>ORGANIQUE</h3><p>Depuis le : 01-01-2026</p></div></div></div>"
|
|
20
|
+
self.assertRaises(ParseError, parse, html)
|