pyIntradel 0.0.7__tar.gz → 0.1.0b2__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pyIntradel
3
- Version: 0.0.7
3
+ Version: 0.1.0b2
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.12
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,13 +100,15 @@ Here is an example of json:
104
100
 
105
101
  ## Usage
106
102
 
107
- The `town` parameter is the name of the town, you can check it here: [towns](pyintradel/api/towns.py)
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=...`).
108
106
 
109
- The intradel.be login form is now protected by an invisible reCAPTCHA, which can
110
- reject a plain login/password POST (this notably happens when the browser blocks
111
- third-party cookies, e.g. in private browsing, but can also affect this library).
112
- As a workaround, you can authenticate once in a real browser, then reuse the
113
- resulting session cookie instead of login/password.
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.
114
112
 
115
113
  ### Python module
116
114
 
@@ -119,19 +117,40 @@ import aiohttp
119
117
  from pyintradel import api
120
118
 
121
119
  async with aiohttp.ClientSession() as sess:
122
- # Using login/password
123
- await api.get_data(sess, login, password, town)
124
-
125
- # Using a session cookie captured from an already logged-in browser
126
- await api.get_data(sess, cookie=cookie)
120
+ await api.get_data(sess, cookie)
127
121
  ```
128
122
 
129
123
  ### Command line
130
124
  ```bash
131
- python3 -m pyintradel.main user passw town
132
- # or
133
125
  python3 -m pyintradel.main --cookie "PHPSESSID=..."
134
126
  ```
135
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/
139
+ ```
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
+
136
155
  ---
137
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,13 +80,15 @@ Here is an example of json:
80
80
 
81
81
  ## Usage
82
82
 
83
- The `town` parameter is the name of the town, you can check it here: [towns](pyintradel/api/towns.py)
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=...`).
84
86
 
85
- The intradel.be login form is now protected by an invisible reCAPTCHA, which can
86
- reject a plain login/password POST (this notably happens when the browser blocks
87
- third-party cookies, e.g. in private browsing, but can also affect this library).
88
- As a workaround, you can authenticate once in a real browser, then reuse the
89
- resulting session cookie instead of login/password.
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.
90
92
 
91
93
  ### Python module
92
94
 
@@ -95,19 +97,40 @@ import aiohttp
95
97
  from pyintradel import api
96
98
 
97
99
  async with aiohttp.ClientSession() as sess:
98
- # Using login/password
99
- await api.get_data(sess, login, password, town)
100
-
101
- # Using a session cookie captured from an already logged-in browser
102
- await api.get_data(sess, cookie=cookie)
100
+ await api.get_data(sess, cookie)
103
101
  ```
104
102
 
105
103
  ### Command line
106
104
  ```bash
107
- python3 -m pyintradel.main user passw town
108
- # or
109
105
  python3 -m pyintradel.main --cookie "PHPSESSID=..."
110
106
  ```
111
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/
119
+ ```
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
+
112
135
  ---
113
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 ValueError("Wrong response received, login/password seems incorrect", response)
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
- name = _name(data)
18
- start_date = _start_date(data)
19
- chip_id = _chip_id(data) or name
20
- details = _details(data)
21
- total = _total(data) or str(len(details))
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 ValueError("Unexpected response structure from intradel")
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
- version = "0.0.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.12"
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
- [project.optional-dependencies]
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 = "py312"
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)