From ec6ed213fbbc33941caf31019cee42997deb4261 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:45:46 +0800 Subject: [PATCH 1/2] MIL-004 request headers (Step 3) Delivers MIL-004 (lecture Step 3): `DEFAULT_HEADERS` (User-Agent, Accept-Language, Accept, Accept-Encoding, Sec-Fetch-*, Priority, Upgrade-Insecure-Requests; no Host and no X-Amzn-Trace-Id) is defined once in `constants.py` and sent by `RequestsPageFetcher`. Tests assert the headers, the absence of Host/trace id, the timeout and read-only behaviour, with no network. Review: code against QC-PY-001 as RC-013 (Go, autonomous, not independent). 38 pytest tests, ruff and mypy --strict pass. Screenshots compare the browser's headers and the bot's headers at httpbin.org/headers. Task: MIL-004#1 Task: MIL-004#2 Task: MIL-004#3 Task: MIL-004#4 --- README.md | 12 +++- docs/artifact-registry.md | 2 +- docs/images/step-3-bot-headers.png | Bin 0 -> 23397 bytes docs/images/step-3-browser-headers.png | Bin 0 -> 25442 bytes docs/sqa/reviews/rc-013-code-mil-004.md | 61 ++++++++++++++++++ src/amazon_price_tracker/constants.py | 26 ++++++++ .../infrastructure/http_client.py | 15 ++++- tests/unit/infrastructure/test_http_client.py | 55 +++++++++++++--- tests/unit/test_constants.py | 6 ++ 9 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 docs/images/step-3-bot-headers.png create mode 100644 docs/images/step-3-browser-headers.png create mode 100644 docs/sqa/reviews/rc-013-code-mil-004.md diff --git a/README.md b/README.md index ea16521..93dc9b1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The assignment is built in four lecture steps. Each step was delivered on its ow | --- | --- | --- | | 1 | Scrape the price from the practice page with BeautifulSoup | done | | 2 | E-mail alert below the target price, secrets in `.env` | done | -| 3 | Browser-like request headers | planned (MIL-004) | +| 3 | Browser-like request headers | done | | 4 | The live Amazon page | planned (MIL-005) | ### Step 1: scrape the price @@ -42,6 +42,16 @@ The screenshots below come from a run where the SMTP connection is replaced by a ![The alert message](docs/images/step-2-email.png) +### Step 3: request headers + +A browser sends headers with every request: the browser and system (`User-Agent`), the preferred language (`Accept-Language`) and more. A bare script sends almost none, so a server can answer in the wrong language or currency, or treat the request as a bot. `DEFAULT_HEADERS` in `constants.py` holds the browser-like set (without `Host` and `X-Amzn-Trace-Id`, as the lecture says) and `RequestsPageFetcher` passes it as `headers=` to `requests.get`. + +What a browser sends to [httpbin.org/headers](https://httpbin.org/headers), and what the bot sends (`Host` is added by `requests` itself): + +![Headers sent by a browser](docs/images/step-3-browser-headers.png) + +![Headers sent by the bot](docs/images/step-3-bot-headers.png) + Repository layout: | Path | What it is for | diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index f6c14b0..3b2f546 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 007 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 | ## Languages diff --git a/docs/images/step-3-bot-headers.png b/docs/images/step-3-bot-headers.png new file mode 100644 index 0000000000000000000000000000000000000000..3536968c3f21dc682db8f732aa23846f9af8a14b GIT binary patch literal 23397 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

$3Z~Si^Wt{o>#BK&$$F&a*@1-KEwu<6fEJo+ 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 literal 0 HcmV?d00001 diff --git a/docs/images/step-3-browser-headers.png b/docs/images/step-3-browser-headers.png new file mode 100644 index 0000000000000000000000000000000000000000..9d8ef47cd88af4af086ffc364ed18629b87ae018 GIT binary patch literal 25442 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 literal 0 HcmV?d00001 diff --git a/docs/sqa/reviews/rc-013-code-mil-004.md b/docs/sqa/reviews/rc-013-code-mil-004.md new file mode 100644 index 0000000..496b751 --- /dev/null +++ b/docs/sqa/reviews/rc-013-code-mil-004.md @@ -0,0 +1,61 @@ +# RC-013 Code review of MIL-004 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-013 | +| CrossReference | [MIL-004], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-004] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical) +- Language reviewer: none +- Mode: autonomous, not independent (AI as stand-in for S01); the author and the stand-in are both the AI working for S01 + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules and functions are snake_case, classes PascalCase, constants UPPER_SNAKE | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (price, target, headers); no abbreviations left unexplained | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | ruff format and ruff check pass: 38 pytest tests, ruff 0 findings, mypy strict 0 errors (26 files) | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes: 38 pytest tests, ruff 0 findings, mypy strict 0 errors (26 files) | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; errors are specific and chained with `raise ... from` where a cause exists | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no shadowed builtins | +| 7 | Files, locks and connections are managed with context managers | Pass | No open files or connections without a context manager | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules and functions have docstrings with Doxygen tags | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No secrets are involved; headers are public browser values | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; the milestone's technical tasks need none (Needs use case: No) | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | Tests exist, are named for behaviour and use no network or real mail server: 38 pytest tests, ruff 0 findings, mypy strict 0 errors (26 files) | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict passes; no Any | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Dependencies are in pyproject.toml with lower bounds; none unused | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in constants.py | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | Imports point inward; no cycles | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The fetcher takes its headers as a constructor argument (extension point); the default is one read-only mapping in constants.py | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers and tests/unit/ mirror as before; only constants and the infrastructure adapter changed | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are English snake_case | + +## Overall Verdict + +Go — all Mandatory criteria pass or are not applicable. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None: no Fail and no Pending criterion | S01 | 2026-10-28 | + +--- + +[MIL-004]: ../../milestones/mil-004-request-headers.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/src/amazon_price_tracker/constants.py b/src/amazon_price_tracker/constants.py index d6e182d..a874669 100644 --- a/src/amazon_price_tracker/constants.py +++ b/src/amazon_price_tracker/constants.py @@ -4,6 +4,8 @@ @brief The only place that defines constants (URLs, limits, environment names). """ +from collections.abc import Mapping +from types import MappingProxyType from typing import Final ## @brief Practice page used instead of Amazon while developing. @@ -23,6 +25,30 @@ TARGET_PRICE: Final[float] = 100.0 ## @brief Seconds to wait for an HTTP response. REQUEST_TIMEOUT_SECONDS: Final[int] = 10 +## @brief Browser-like request headers, read-only. +# Copied from a real browser (see httpbin.org/headers) as the lecture says, +# without Host and X-Amzn-Trace-Id. No br/zstd: requests may not decode them. +DEFAULT_HEADERS: Final[Mapping[str, str]] = MappingProxyType( + { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8", + "Accept": ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,*/*;q=0.8" + ), + "Accept-Encoding": "gzip, deflate", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Priority": "u=0, i", + } +) + ## @brief SMTP submission port (STARTTLS). SMTP_PORT: Final[int] = 587 diff --git a/src/amazon_price_tracker/infrastructure/http_client.py b/src/amazon_price_tracker/infrastructure/http_client.py index 1572ce5..ffe66d2 100644 --- a/src/amazon_price_tracker/infrastructure/http_client.py +++ b/src/amazon_price_tracker/infrastructure/http_client.py @@ -4,14 +4,23 @@ @brief Downloads a page with requests. """ +from collections.abc import Mapping + import requests -from amazon_price_tracker.constants import REQUEST_TIMEOUT_SECONDS +from amazon_price_tracker.constants import DEFAULT_HEADERS, REQUEST_TIMEOUT_SECONDS class RequestsPageFetcher: """PageFetcher port implemented with requests.""" + def __init__(self, headers: Mapping[str, str] = DEFAULT_HEADERS) -> None: + """Keep the headers sent with every request. + + @param headers Request headers; browser-like by default. + """ + self._headers = headers + def fetch(self, url: str) -> str: """Return the HTML text of the page at url. @@ -19,6 +28,8 @@ class RequestsPageFetcher: @return The response body as text. @throws requests.HTTPError When the server answers with an error status. """ - response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS) + response = requests.get( + url, headers=dict(self._headers), timeout=REQUEST_TIMEOUT_SECONDS + ) response.raise_for_status() return response.text diff --git a/tests/unit/infrastructure/test_http_client.py b/tests/unit/infrastructure/test_http_client.py index 46e03ba..e38037a 100644 --- a/tests/unit/infrastructure/test_http_client.py +++ b/tests/unit/infrastructure/test_http_client.py @@ -1,9 +1,11 @@ import pytest import requests -from amazon_price_tracker.constants import REQUEST_TIMEOUT_SECONDS +from amazon_price_tracker.constants import DEFAULT_HEADERS, REQUEST_TIMEOUT_SECONDS from amazon_price_tracker.infrastructure.http_client import RequestsPageFetcher +Call = tuple[str, dict[str, str], float] + class FakeResponse: def __init__(self, text: str, status_code: int) -> None: @@ -15,13 +17,11 @@ class FakeResponse: raise requests.HTTPError(f"status {self.status_code}") -def _patch_get( - monkeypatch: pytest.MonkeyPatch, response: FakeResponse -) -> list[tuple[str, float]]: - calls: list[tuple[str, float]] = [] +def _patch_get(monkeypatch: pytest.MonkeyPatch, response: FakeResponse) -> list[Call]: + calls: list[Call] = [] - def fake_get(url: str, timeout: float) -> FakeResponse: - calls.append((url, timeout)) + def fake_get(url: str, headers: dict[str, str], timeout: float) -> FakeResponse: + calls.append((url, headers, timeout)) return response monkeypatch.setattr(requests, "get", fake_get) @@ -36,7 +36,46 @@ def test_fetch_returns_text_using_url_and_timeout( html = RequestsPageFetcher().fetch("https://example.test/") assert html == "

hi

" - assert calls == [("https://example.test/", REQUEST_TIMEOUT_SECONDS)] + assert calls == [ + ("https://example.test/", dict(DEFAULT_HEADERS), REQUEST_TIMEOUT_SECONDS) + ] + + +def test_fetch_sends_user_agent_and_accept_language( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _patch_get(monkeypatch, FakeResponse("", 200)) + + RequestsPageFetcher().fetch("https://example.test/") + + headers = calls[0][1] + assert headers["User-Agent"] + assert headers["Accept-Language"] + + +def test_fetch_omits_host_and_trace_id_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _patch_get(monkeypatch, FakeResponse("", 200)) + + RequestsPageFetcher().fetch("https://example.test/") + + assert "Host" not in calls[0][1] + assert "X-Amzn-Trace-Id" not in calls[0][1] + + +def test_fetch_uses_overriding_headers(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _patch_get(monkeypatch, FakeResponse("", 200)) + + RequestsPageFetcher(headers={"User-Agent": "custom"}).fetch("https://example.test/") + + assert calls[0][1] == {"User-Agent": "custom"} + assert calls[0][2] == REQUEST_TIMEOUT_SECONDS + + +def test_default_headers_are_read_only() -> None: + with pytest.raises(TypeError): + DEFAULT_HEADERS["User-Agent"] = "x" # type: ignore[index] def test_fetch_raises_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index d2f6508..837b56f 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -36,6 +36,12 @@ def test_env_names_are_unique() -> None: assert len(set(ENV_NAMES)) == len(ENV_NAMES) +def test_default_headers_are_non_empty() -> None: + """Every default header has a name and a value.""" + assert constants.DEFAULT_HEADERS + assert all(k.strip() and v.strip() for k, v in constants.DEFAULT_HEADERS.items()) + + def test_smtp_port_is_starttls_port() -> None: """The SMTP port is 587.""" assert constants.SMTP_PORT == 587 -- 2.54.0 From 0db472711a27e840bad41ff8ef7ca5ac2a1aed14 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:45:50 +0800 Subject: [PATCH 2/2] Resolve pending commit links of MIL-004 --- docs/sqa/reviews/rc-013-code-mil-004.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-013-code-mil-004.md b/docs/sqa/reviews/rc-013-code-mil-004.md index 496b751..1b7273f 100644 --- a/docs/sqa/reviews/rc-013-code-mil-004.md +++ b/docs/sqa/reviews/rc-013-code-mil-004.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [ec6ed21] | --- @@ -59,3 +59,4 @@ Go — all Mandatory criteria pass or are not applicable. [MIL-004]: ../../milestones/mil-004-request-headers.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[ec6ed21]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/047-amazon-price-tracker/commit/ec6ed213fbbc33941caf31019cee42997deb4261 -- 2.54.0