Skip to content

March 2026 Release#1883

Merged
robbrad merged 62 commits intomasterfrom
march-2026-release
Mar 14, 2026
Merged

March 2026 Release#1883
robbrad merged 62 commits intomasterfrom
march-2026-release

Conversation

@robbrad
Copy link
Copy Markdown
Owner

@robbrad robbrad commented Mar 14, 2026

Combined release merging 15 PRs into a single release branch.

Fix

Feat

Chore

Closes

Closes #1836, closes #1831, closes #1844, closes #1846, closes #1845, closes #1851, closes #1853, closes #1848, closes #1855, closes #1858, closes #1861, closes #1864, closes #1863, closes #1867, closes #1870, closes #1872, closes #1868, closes #1876, closes #1879, closes #1880, closes #1504, closes #1869

PRs Included

#1841, #1843, #1847, #1849, #1852, #1854, #1856, #1857, #1860, #1866, #1871, #1873, #1877, #1878, #1882

Merge Conflicts Resolved

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for London Borough of Hammersmith and Fulham bin collection lookups
    • Added support for North Warwickshire Borough Council bin collection lookups
  • Bug Fixes

    • Updated bin collection data retrieval for multiple councils to work with website changes
    • Improved parsing reliability and consistency for several council bin schedules
  • Documentation

    • Updated council reference documentation with new entries and parameter guidance
    • Clarified command examples for several councils

m26dvd and others added 30 commits February 2, 2026 21:51
fix: #1836 - fix: London Borough Redbridge
fix: #1831 - Harborough District Council
fix: #1504 - Adding Hammersmith & Fulham
fix: HarboroughDistrictCouncil
Bumps [pip](https://github.com/pypa/pip) from 25.3 to 26.0.
- [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst)
- [Commits](pypa/pip@25.3...26.0)

---
updated-dependencies:
- dependency-name: pip
  dependency-version: '26.0'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
fix: #1846 - Powys Council
fix: #1845 - Mid Suffolk District Council
Richmond upon Thames doesn't work anymore.
Bin collections can now be queried with  Unique Property Reference Number (UPRN) in QS.
Use "https://www.richmond.gov.uk/my_richmond" as URL, will request https://www.richmond.gov.uk/my_richmond/?pid=<YourUKPropertyID> and parse. 

"https://www.findmyaddress.co.uk/search" can be used for finding the UPRN.
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.0.0 to 12.1.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](python-pillow/Pillow@12.0.0...12.1.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
fix: #1851 Bromley Borough Council
fix: #1853 - Wakefield City Council
fix: #1848 - Redcar and Cleveland Council
fix: CumberlandCouncil - correct year assignment for all months - previous had accidentally hardcoded the year as 2025 for all months except Jan/Feb
further feedback from AI agent. this fixes the issues flagged and builds on previous commit
fix: #1855 - Barking & Dagenham
fix: #1858 - Cumberland Council
fix: #1861 - North East Derbyshire District Council
fix: #1864 - Leeds City Council
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
m26dvd and others added 19 commits March 9, 2026 21:10
fix: #1876 -  Bath and North East Somerset
fix: #1869 - Adding North Warwickshire Borough Council
fix: #1872 - Broxtowe Borough Council
Bumps [black](https://github.com/psf/black) from 25.1.0 to 26.3.1.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](psf/black@25.1.0...26.3.1)

---
updated-dependencies:
- dependency-name: black
  dependency-version: 26.3.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
another update as didnt work in execution
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 14, 2026

Warning

Rate limit exceeded

@robbrad has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 25 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 78652f40-e20b-4a94-826f-a08ca947107b

📥 Commits

Reviewing files that changed from the base of the PR and between 026d167 and 58eb0ff.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (57)
  • uk_bin_collection/uk_bin_collection/councils/BarnsleyMBCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BasingstokeCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BathAndNorthEastSomersetCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BedfordshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BirminghamCityCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BostonBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BracknellForestCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BradfordMDC.py
  • uk_bin_collection/uk_bin_collection/councils/BraintreeDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BristolCityCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BuckinghamshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CannockChaseDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CardiffCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CastlepointDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CherwellDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/ChorleyCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CornwallCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CoventryCityCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/DarlingtonBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/DenbighshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/DerbyshireDalesDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/EnfieldCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/FarehamBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/FenlandDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/FifeCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/GatesheadCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/HullCityCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/IslingtonCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/KingsLynnandWestNorfolkBC.py
  • uk_bin_collection/uk_bin_collection/councils/LondonBoroughLewisham.py
  • uk_bin_collection/uk_bin_collection/councils/LondonBoroughOfRichmondUponThames.py
  • uk_bin_collection/uk_bin_collection/councils/MiddlesbroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/MidlothianCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/NewcastleUnderLymeCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/NorthLincolnshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/RhonddaCynonTaffCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/RotherDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SomersetCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SouthCambridgeshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SouthHamsDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SouthKestevenDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SouthNorfolkCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SouthOxfordshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SouthRibbleCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SurreyHeathBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/TandridgeDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/TestValleyBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/TonbridgeAndMallingBC.py
  • uk_bin_collection/uk_bin_collection/councils/ValeofWhiteHorseCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WaverleyBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WealdenDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WiltshireCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WokingBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WyreCouncil.py
📝 Walkthrough

Walkthrough

This PR updates GitHub Actions in CI workflows, modifies Poetry configuration structure, and refactors multiple UK council bin collection scrapers. Changes include replacing web scraping with REST API calls for some councils, adopting calendar-based data sources for others, updating HTML selectors and request parameters, adding new councils, and improving user-agent handling. Wiki documentation and test data are correspondingly updated.

Changes

Cohort / File(s) Summary
GitHub Workflows & Configuration
.github/workflows/ha_compatibility_test.yml, .github/workflows/release.yml, pyproject.toml
Updated GitHub Actions versions: artifact upload v6→v7, docker/login-action v3→v4, docker/build-push-action v6→v7. Poetry dev-dependencies section renamed to group.dev.dependencies.
Test Data & Documentation
uk_bin_collection/tests/input.json, wiki/Councils.md
Test data updated for LeedsCityCouncil (UPRN-only, removed Selenium), added entries for LondonBoroughHammersmithandFulham and NorthWarwickshireBoroughCouncil. Wiki extended with new councils and updated command syntax reflecting API/Selenium changes.
API & Data Source Migration
BathAndNorthEastSomersetCouncil.py, LeedsCityCouncil.py, HinckleyandBosworthBoroughCouncil.py, LondonBoroughOfRichmondUponThames.py, HarboroughDistrictCouncil.py, LondonBoroughHavering.py
Significant refactors: replaced Selenium scraping with REST/ICS APIs, changed endpoints/auth keys, introduced session-based HTTP flows, added date-window logic and regex-based parsing patterns.
HTML Parsing & Selector Updates
BarkingDagenham.py, BroxbourneCouncil.py, BroxtoweBoroughCouncil.py, CumberlandCouncil.py, MertonCouncil.py, MidlothianCouncil.py, PowysCouncil.py, SwaleBoroughCouncil.py, NewhamCouncil.py, NuneatonBedworthBoroughCouncil.py, LondonBoroughRedbridge.py
Updated CSS class selectors, element IDs/names, and HTML parsing logic; removed cookie banner handling; refactored list/grid-based extraction; added food waste section parsing; disabled SSL verification in one instance; renamed calendar keys and updated date formats.
Minor Logic & User-Agent Updates
BromleyBoroughCouncil.py, EastleighBoroughCouncil.py, WakefieldCityCouncil.py, NorthEastDerbyshireDistrictCouncil.py, MidSuffolkDistrictCouncil.py, RedcarandClevelandCouncil.py
Added or updated custom user-agent strings in WebDriver initialization; added UPRN zero-padding; adjusted date string splitting and place_id extraction logic.
New Council Implementations
LondonBoroughHammersmithandFulham.py, NorthWarwickshireBoroughCouncil.py
Introduced two new council scraper classes: Hammersmith & Fulham uses requests/BeautifulSoup for postcode-based lookup; North Warwickshire implements multi-step API workflow with session authentication and multiple data-broker requests.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client
    participant Login API
    participant Data Broker API

    User->>Client: Request bin data with UPRN
    activate Client
    Client->>Login API: GET (establish session & extract auth ID)
    activate Login API
    Login API-->>Client: Session + Auth ID
    deactivate Login API
    
    Client->>Data Broker API: POST food waste request (with auth ID)
    activate Data Broker API
    Data Broker API-->>Client: Food waste items
    deactivate Data Broker API
    
    Client->>Data Broker API: POST refuse request (with auth ID)
    activate Data Broker API
    Data Broker API-->>Client: Refuse items
    deactivate Data Broker API
    
    Client->>Data Broker API: POST recycling request (with auth ID)
    activate Data Broker API
    Data Broker API-->>Client: Recycling items
    deactivate Data Broker API
    
    Client->>Data Broker API: POST garden request (with auth ID)
    activate Data Broker API
    Data Broker API-->>Client: Garden items
    deactivate Data Broker API
    
    Client->>Client: Aggregate and sort by date
    Client-->>User: Unified bin collection list
    deactivate Client
Loading
sequenceDiagram
    actor User
    participant Client
    participant Web Server
    participant ICS Server

    User->>Client: Request bin data with UPRN
    activate Client
    
    Client->>Client: Generate ICS URL using UPRN
    Client->>ICS Server: Fetch ICS calendar (365 days)
    activate ICS Server
    ICS Server-->>Client: ICS events
    deactivate ICS Server
    
    Client->>Client: Parse events, extract bin types
    Client->>Client: Normalize collection type names
    Client->>Client: Aggregate and sort by date
    Client-->>User: Structured bin collection list
    deactivate Client
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • dp247

Poem

🐰 Hopping through councils from shore to shore,
APIs and selectors, refactored once more,
From Selenium dreams to REST calls so clean,
The bins shall be gathered, a parsing machine! ✨🗑️

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The pull request title 'March 2026 Release' is vague and generic, lacking specificity about the actual changes in the changeset. Consider using a more descriptive title that highlights the primary focus, such as 'Release: Council scraper fixes and dependency updates' or 'March 2026 Release: Update scrapers and dependencies'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch march-2026-release
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 14, 2026

❌ 11 Tests Failed:

Tests completed Failed Passed Skipped
77 11 66 0
View the top 3 failed test(s) by shortest run time
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[LondonBoroughHammersmithandFulham]
Stack Traces | 0.477s run time
fixturefunc = <function scrape_step at 0x7f04e3f8b6a0>
request = <FixtureRequest for <Function test_scenario_outline[LondonBoroughHammersmithandFulham]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f04e526c050>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:82: in get_and_parse_data
    bin_data_dict = self.parse_data(page, url=address_url, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <LondonBoroughHammersmithandFulham.CouncilClass object at 0x7f04e34f23c0>
page = <Response [202]>
kwargs = {'council_module_str': 'LondonBoroughHammersmithandFulham', 'dev_mode': False, 'headless': True, 'local_browser': False, ...}
user_postcode = 'W120BQ', bindata = {'bins': []}
URI = 'https://www.lbhf.gov.uk/bin-recycling-day/results?postcode=W120BQ'
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
session = <requests.sessions.Session object at 0x7f04e34f1250>
response = <Response [202]>, soup = b''

    def parse_data(self, page: str, **kwargs) -> dict:
    
        user_postcode = kwargs.get("postcode")
        check_postcode(user_postcode)
        bindata = {"bins": []}
    
        user_postcode = user_postcode.strip().replace(" ", "")
    
        URI = f"https://www.lbhf.gov.uk/bin-recycling-day/results?postcode={user_postcode}"
        UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
        session = requests.session()
        session.headers.update({"User-Agent": UA})
        # Make the GET request
        response = session.get(URI)
        response.raise_for_status()
    
        soup = BeautifulSoup(response.content, features="html.parser")
        results = soup.find("div", {"class": "nearest-search-results"})
>       ol = results.find("ol")
E       AttributeError: 'NoneType' object has no attribute 'find'

.../uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py:36: AttributeError
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[NewhamCouncil]
Stack Traces | 0.932s run time
fixturefunc = <function scrape_step at 0x7f472457eac0>
request = <FixtureRequest for <Function test_scenario_outline[NewhamCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f4725624e30>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <NewhamCouncil.CouncilClass object at 0x7f47240263c0>
page = <Response [200]>
kwargs = {'council_module_str': 'NewhamCouncil', 'dev_mode': False, 'headless': True, 'local_browser': False, ...}
user_uprn = '46077811'
url = 'https://bincollection.newham.gov..../Details/Index/46077811'
soup = <!DOCTYPE html>

<html>
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="vie...               </p>
</div>
</div>
</div>
</div>
</main>
<script src="/Scripts/JavaScript1.js"></script>
</body>
</html>
data = {'bins': [{'collectionDate': '17/03/2026', 'type': 'Domestic'}, {'collectionDate': '17/03/2026', 'type': 'Recycling'}]}
sections = [<div class="card h-100">
<div class="card-header">Your <b>Domestic</b> Collection Day</div>
<div class="card-body">
<...od waste collection service to your property. The new service will be coming soon.
                </p>
</div>
</div>]
sections_recycling = [<div class="card h-100 card-recycling">
<div class="card-header">Your <b>Recycling</b> Collection Day</div>
<div clas...<b>Next </b><mark>Tuesday</mark> 3/17/2026<br/>
<b>Previous </b><mark>Tuesday</mark> 3/10/2026<br/>
</p>
</div>
</div>]

    def parse_data(self, page: str, **kwargs) -> dict:
    
        try:
            user_uprn = kwargs.get("uprn")
            check_uprn(user_uprn)
            url = f"https://bincollection.newham.gov.uk/Details/Index/{user_uprn}"
            if not user_uprn:
                # This is a fallback for if the user stored a URL in old system. Ensures backwards compatibility.
                url = kwargs.get("url")
        except Exception as e:
            raise ValueError(f"Error getting identifier: {str(e)}")
    
        # Make a BS4 object
        page = requests.get(url, verify=False)
        soup = BeautifulSoup(page.text, "html.parser")
        soup.prettify
    
        # Form a JSON wrapper
        data = {"bins": []}
    
        # Find section with bins in
        sections = soup.find_all("div", {"class": "card h-100"})
    
        # there may also be a recycling one too
        sections_recycling = soup.find_all(
            "div", {"class": "card h-100 card-recycling"}
        )
        if len(sections_recycling) > 0:
            sections.append(sections_recycling[0])
    
        # as well as one for food waste
        sections_food_waste = soup.find_all(
            "div", {"class": "card h-100 card-food"}
        )
        if len(sections_food_waste) > 0:
            sections.append(sections_food_waste[0])
    
        # For each bin section, get the text and the list elements
        for item in sections:
            header = item.find("div", {"class": "card-header"})
            bin_type_element = header.find_next("b")
            if bin_type_element is not None:
                bin_type = bin_type_element.text
                array_expected_types = ["Domestic", "Recycling", "Food Waste"]
                if bin_type in array_expected_types:
                    date = (
                        item.find_next("p", {"class": "card-text"})
                        .find_next("mark")
>                       .next_sibling.strip()
                    )
E                   AttributeError: 'NoneType' object has no attribute 'next_sibling'

.../uk_bin_collection/councils/NewhamCouncil.py:57: AttributeError
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[MidSuffolkDistrictCouncil]
Stack Traces | 62.6s run time
fixturefunc = <function scrape_step at 0x7fcc54e0e160>
request = <FixtureRequest for <Function test_scenario_outline[MidSuffolkDistrictCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7fcc697cbec0>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
.../uk_bin_collection/councils/MidSuffolkDistrictCouncil.py:124: in parse_data
    collection_date = datetime.strptime(date_str, "%a %d %b %Y")
....../usr/lib/python3.12/_strptime.py:554: in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

data_string = 'Tue 31 Mar 2026, Tue 14 Apr 2026', format = '%a %d %b %Y'

    def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
        """Return a 2-tuple consisting of a time struct and an int containing
        the number of microseconds based on the input string and the
        format string."""
    
        for index, arg in enumerate([data_string, format]):
            if not isinstance(arg, str):
                msg = "strptime() argument {} must be str, not {}"
                raise TypeError(msg.format(index, type(arg)))
    
        global _TimeRE_cache, _regex_cache
        with _cache_lock:
            locale_time = _TimeRE_cache.locale_time
            if (_getlang() != locale_time.lang or
                time.tzname != locale_time.tzname or
                time.daylight != locale_time.daylight):
                _TimeRE_cache = TimeRE()
                _regex_cache.clear()
                locale_time = _TimeRE_cache.locale_time
            if len(_regex_cache) > _CACHE_MAX_SIZE:
                _regex_cache.clear()
            format_regex = _regex_cache.get(format)
            if not format_regex:
                try:
                    format_regex = _TimeRE_cache.compile(format)
                # KeyError raised when a bad format is found; can be specified as
                # \\, in which case it was a stray % but with a space after it
                except KeyError as err:
                    bad_directive = err.args[0]
                    if bad_directive == "\\":
                        bad_directive = "%"
                    del err
                    raise ValueError("'%s' is a bad directive in format '%s'" %
                                        (bad_directive, format)) from None
                # IndexError only occurs when the format string is "%"
                except IndexError:
                    raise ValueError("stray %% in format '%s'" % format) from None
                _regex_cache[format] = format_regex
        found = format_regex.match(data_string)
        if not found:
            raise ValueError("time data %r does not match format %r" %
                             (data_string, format))
        if len(data_string) != found.end():
>           raise ValueError("unconverted data remains: %s" %
                              data_string[found.end():])
E           ValueError: unconverted data remains: , Tue 14 Apr 2026

....../usr/lib/python3.12/_strptime.py:336: ValueError
View the full list of 8 ❄️ flaky test(s)
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[BarkingDagenham]

Flake rate in main: 75.90% (Passed 87 times, Failed 274 times)

Stack Traces | 12.1s run time
fixturefunc = <function scrape_step at 0x7f3b62b7b6a0>
request = <FixtureRequest for <Function test_scenario_outline[BarkingDagenham]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f3b63e67590>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
.../uk_bin_collection/councils/BarkingDagenham.py:54: in parse_data
    post_code_input = wait.until(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selenium.webdriver.support.wait.WebDriverWait (session="8a6d076fc7e19292c09547a5a214455b")>
method = <function element_to_be_clickable.<locals>._predicate at 0x7f3b72e3cb80>
message = 'Postcode input not found'

    def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
        """Wait until the method returns a value that is not False.
    
        Calls the method provided with the driver as an argument until the
        return value does not evaluate to ``False``.
    
        Parameters:
        -----------
        method: callable(WebDriver)
            - A callable object that takes a WebDriver instance as an argument.
    
        message: str
            - Optional message for :exc:`TimeoutException`
    
        Return:
        -------
        object: T
            - The result of the last call to `method`
    
        Raises:
        -------
        TimeoutException
            - If 'method' does not return a truthy value within the WebDriverWait
            object's timeout
    
        Example:
        --------
        >>> from selenium.webdriver.common.by import By
        >>> from selenium.webdriver.support.ui import WebDriverWait
        >>> from selenium.webdriver.support import expected_conditions as EC
    
        # Wait until an element is visible on the page
        >>> wait = WebDriverWait(driver, 10)
        >>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
        >>> print(element.text)
        """
        screen = None
        stacktrace = None
    
        end_time = time.monotonic() + self._timeout
        while True:
            try:
                value = method(self._driver)
                if value:
                    return value
            except self._ignored_exceptions as exc:
                screen = getattr(exc, "screen", None)
                stacktrace = getattr(exc, "stacktrace", None)
            if time.monotonic() > end_time:
                break
            time.sleep(self._poll)
>       raise TimeoutException(message, screen, stacktrace)
E       selenium.common.exceptions.TimeoutException: Message: Postcode input not found
E       Stacktrace:
E       #0 0x5602611bcb6a <unknown>
E       #1 0x560260bcfa32 <unknown>
E       #2 0x560260c24d66 <unknown>
E       #3 0x560260c24fa1 <unknown>
E       #4 0x560260c70144 <unknown>
E       #5 0x560260c6d369 <unknown>
E       #6 0x560260c16c0f <unknown>
E       #7 0x560260c179d1 <unknown>
E       #8 0x5602611816b9 <unknown>
E       #9 0x5602611845c1 <unknown>
E       #10 0x56026116de29 <unknown>
E       #11 0x56026118517e <unknown>
E       #12 0x5602611544b0 <unknown>
E       #13 0x5602611a9578 <unknown>
E       #14 0x5602611a974b <unknown>
E       #15 0x5602611bb1a3 <unknown>
E       #16 0x7fcb29e26aa4 <unknown>
E       #17 0x7fcb29eb3a64 __clone

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../webdriver/support/wait.py:146: TimeoutException
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[BostonBoroughCouncil]

Flake rate in main: 80.98% (Passed 70 times, Failed 298 times)

Stack Traces | 39.2s run time
fixturefunc = <function scrape_step at 0x7f04e3f8b6a0>
request = <FixtureRequest for <Function test_scenario_outline[BostonBoroughCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f04e526c050>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
.../uk_bin_collection/councils/BostonBoroughCouncil.py:97: in parse_data
    WebDriverWait(driver, 10).until(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selenium.webdriver.support.wait.WebDriverWait (session="76a3bf9cd8ae487d089c9c1be0c3b3d1")>
method = <function presence_of_element_located.<locals>._predicate at 0x7f04f8348180>
message = ''

    def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
        """Wait until the method returns a value that is not False.
    
        Calls the method provided with the driver as an argument until the
        return value does not evaluate to ``False``.
    
        Parameters:
        -----------
        method: callable(WebDriver)
            - A callable object that takes a WebDriver instance as an argument.
    
        message: str
            - Optional message for :exc:`TimeoutException`
    
        Return:
        -------
        object: T
            - The result of the last call to `method`
    
        Raises:
        -------
        TimeoutException
            - If 'method' does not return a truthy value within the WebDriverWait
            object's timeout
    
        Example:
        --------
        >>> from selenium.webdriver.common.by import By
        >>> from selenium.webdriver.support.ui import WebDriverWait
        >>> from selenium.webdriver.support import expected_conditions as EC
    
        # Wait until an element is visible on the page
        >>> wait = WebDriverWait(driver, 10)
        >>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
        >>> print(element.text)
        """
        screen = None
        stacktrace = None
    
        end_time = time.monotonic() + self._timeout
        while True:
            try:
                value = method(self._driver)
                if value:
                    return value
            except self._ignored_exceptions as exc:
                screen = getattr(exc, "screen", None)
                stacktrace = getattr(exc, "stacktrace", None)
            if time.monotonic() > end_time:
                break
            time.sleep(self._poll)
>       raise TimeoutException(message, screen, stacktrace)
E       selenium.common.exceptions.TimeoutException: Message: 
E       Stacktrace:
E       #0 0x562930cbbb6a <unknown>
E       #1 0x5629306cea32 <unknown>
E       #2 0x562930723d66 <unknown>
E       #3 0x562930723fa1 <unknown>
E       #4 0x56293076f144 <unknown>
E       #5 0x56293076c369 <unknown>
E       #6 0x562930715c0f <unknown>
E       #7 0x5629307169d1 <unknown>
E       #8 0x562930c806b9 <unknown>
E       #9 0x562930c835c1 <unknown>
E       #10 0x562930c6ce29 <unknown>
E       #11 0x562930c8417e <unknown>
E       #12 0x562930c534b0 <unknown>
E       #13 0x562930ca8578 <unknown>
E       #14 0x562930ca874b <unknown>
E       #15 0x562930cba1a3 <unknown>
E       #16 0x7fb1af2b2aa4 <unknown>
E       #17 0x7fb1af33fa64 __clone

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../webdriver/support/wait.py:146: TimeoutException
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[CastlepointDistrictCouncil]

Flake rate in main: 99.19% (Passed 3 times, Failed 366 times)

Stack Traces | 0.791s run time
fixturefunc = <function validate_output_step at 0x7f472457e5c0>
request = <FixtureRequest for <Function test_scenario_outline[CastlepointDistrictCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f4725624e30>}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:114: in validate_output_step
    assert file_handler.validate_json_schema(
.../step_defs/step_helpers/file_handler.py:40: in validate_json_schema
    validate(instance=json_data, schema=schema)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

instance = {'bins': []}
schema = {'$ref': '#/definitions/BinData', '$schema': 'http://json-schema.org/draft-06/schema#', 'definitions': {'Bin': {'addit...ems': {'$ref': '#/definitions/Bin'}, 'minItems': 1, 'type': 'array'}}, 'required': ['bins'], 'title': 'BinData', ...}}}
cls = <class 'jsonschema.validators.Draft6Validator'>, args = (), kwargs = {}
validator = Draft6Validator(schema={'$ref': '#/definitions/BinData', '$schema': 'http://json-...ft-06/schema#', 'definitions': {'B...nitions/Bin'}, 'minItems': 1, 'type': 'array'}}, 'required': ['bins'], 'title': 'BinData', ...}}}, format_checker=None)
error = <ValidationError: '[] should be non-empty'>

    def validate(instance, schema, cls=None, *args, **kwargs):  # noqa: D417
        """
        Validate an instance under the given schema.
    
            >>> validate([2, 3, 4], {"maxItems": 2})
            Traceback (most recent call last):
                ...
            ValidationError: [2, 3, 4] is too long
    
        :func:`~jsonschema.validators.validate` will first verify that the
        provided schema is itself valid, since not doing so can lead to less
        obvious error messages and fail in less obvious or consistent ways.
    
        If you know you have a valid schema already, especially
        if you intend to validate multiple instances with
        the same schema, you likely would prefer using the
        `jsonschema.protocols.Validator.validate` method directly on a
        specific validator (e.g. ``Draft202012Validator.validate``).
    
    
        Arguments:
    
            instance:
    
                The instance to validate
    
            schema:
    
                The schema to validate with
    
            cls (jsonschema.protocols.Validator):
    
                The class that will be used to validate the instance.
    
        If the ``cls`` argument is not provided, two things will happen
        in accordance with the specification. First, if the schema has a
        :kw:`$schema` keyword containing a known meta-schema [#]_ then the
        proper validator will be used. The specification recommends that
        all schemas contain :kw:`$schema` properties for this reason. If no
        :kw:`$schema` property is found, the default validator class is the
        latest released draft.
    
        Any other provided positional and keyword arguments will be passed
        on when instantiating the ``cls``.
    
        Raises:
    
            `jsonschema.exceptions.ValidationError`:
    
                if the instance is invalid
    
            `jsonschema.exceptions.SchemaError`:
    
                if the schema itself is invalid
    
        .. rubric:: Footnotes
        .. [#] known by a validator registered with
            `jsonschema.validators.validates`
    
        """
        if cls is None:
            cls = validator_for(schema)
    
        cls.check_schema(schema)
        validator = cls(schema, *args, **kwargs)
        error = exceptions.best_match(validator.iter_errors(instance))
        if error is not None:
>           raise error
E           jsonschema.exceptions.ValidationError: [] should be non-empty
E           
E           Failed validating 'minItems' in schema['properties']['bins']:
E               {'type': 'array', 'items': {'$ref': '#/definitions/Bin'}, 'minItems': 1}
E           
E           On instance['bins']:
E               []

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/jsonschema/validators.py:1332: ValidationError
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[GatesheadCouncil]

Flake rate in main: 85.96% (Passed 49 times, Failed 300 times)

Stack Traces | 61.8s run time
fixturefunc = <function scrape_step at 0x7f3b62b7b6a0>
request = <FixtureRequest for <Function test_scenario_outline[GatesheadCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f3b63e67590>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
.../uk_bin_collection/councils/GatesheadCouncil.py:72: in parse_data
    WebDriverWait(driver, 10).until(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selenium.webdriver.support.wait.WebDriverWait (session="1b488ed3844c6b29fb1baacaf53bdaaf")>
method = <function element_to_be_clickable.<locals>._predicate at 0x7f3b61e211c0>
message = ''

    def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
        """Wait until the method returns a value that is not False.
    
        Calls the method provided with the driver as an argument until the
        return value does not evaluate to ``False``.
    
        Parameters:
        -----------
        method: callable(WebDriver)
            - A callable object that takes a WebDriver instance as an argument.
    
        message: str
            - Optional message for :exc:`TimeoutException`
    
        Return:
        -------
        object: T
            - The result of the last call to `method`
    
        Raises:
        -------
        TimeoutException
            - If 'method' does not return a truthy value within the WebDriverWait
            object's timeout
    
        Example:
        --------
        >>> from selenium.webdriver.common.by import By
        >>> from selenium.webdriver.support.ui import WebDriverWait
        >>> from selenium.webdriver.support import expected_conditions as EC
    
        # Wait until an element is visible on the page
        >>> wait = WebDriverWait(driver, 10)
        >>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
        >>> print(element.text)
        """
        screen = None
        stacktrace = None
    
        end_time = time.monotonic() + self._timeout
        while True:
            try:
                value = method(self._driver)
                if value:
                    return value
            except self._ignored_exceptions as exc:
                screen = getattr(exc, "screen", None)
                stacktrace = getattr(exc, "stacktrace", None)
            if time.monotonic() > end_time:
                break
            time.sleep(self._poll)
>       raise TimeoutException(message, screen, stacktrace)
E       selenium.common.exceptions.TimeoutException: Message: 
E       Stacktrace:
E       #0 0x558551ef0b6a <unknown>
E       #1 0x558551903a32 <unknown>
E       #2 0x558551958d66 <unknown>
E       #3 0x558551958fa1 <unknown>
E       #4 0x5585519a4144 <unknown>
E       #5 0x5585519a1369 <unknown>
E       #6 0x55855194ac0f <unknown>
E       #7 0x55855194b9d1 <unknown>
E       #8 0x558551eb56b9 <unknown>
E       #9 0x558551eb85c1 <unknown>
E       #10 0x558551ea1e29 <unknown>
E       #11 0x558551eb917e <unknown>
E       #12 0x558551e884b0 <unknown>
E       #13 0x558551edd578 <unknown>
E       #14 0x558551edd74b <unknown>
E       #15 0x558551eef1a3 <unknown>
E       #16 0x7f28e0f1baa4 <unknown>
E       #17 0x7f28e0fa8a64 __clone

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../webdriver/support/wait.py:146: TimeoutException
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[LondonBoroughOfRichmondUponThames]

Flake rate in main: 99.71% (Passed 1 times, Failed 340 times)

Stack Traces | 0.006s run time
fixturefunc = <function scrape_step at 0x7f04e3f8b6a0>
request = <FixtureRequest for <Function test_scenario_outline[LondonBoroughOfRichmondUponThames]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f04e526c050>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <LondonBoroughOfRichmondUponThames.CouncilClass object at 0x7f04e30a1fd0>
page = ''
kwargs = {'council_module_str': 'LondonBoroughOfRichmondUponThames', 'dev_mode': False, 'headless': True, 'local_browser': False, ...}
base_url = 'https://www.richmond.gov..../services/waste_and_recycling/collection_days/'
pid_arg = None, paon = 'March Road', pid_from_url = None, pid_from_paon = None

    def parse_data(self, page: str, **kwargs) -> dict:
        base_url = kwargs.get("url") or page
        pid_arg = kwargs.get("pid")
        paon = kwargs.get("paon")
    
        # work out final URL, but DO NOT add #my_waste
        pid_from_url = self._pid_from_url(base_url)
        pid_from_paon = self._pid_from_paon(paon)
    
        if "pid=" in (base_url or ""):
            target_url = base_url
        elif pid_arg or pid_from_paon:
            pid = pid_arg or pid_from_paon
            sep = "&" if "?" in (base_url or "") else "?"
            target_url = f"{base_url}{sep}pid={pid}"
        else:
>           raise ValueError(
                "Richmond: supply a URL that already has ?pid=... OR put PID in the House Number field."
            )
E           ValueError: Richmond: supply a URL that already has ?pid=... OR put PID in the House Number field.

.../uk_bin_collection/councils/LondonBoroughOfRichmondUponThames.py:36: ValueError
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[NorthEastDerbyshireDistrictCouncil]

Flake rate in main: 50.32% (Passed 155 times, Failed 157 times)

Stack Traces | 66.1s run time
fixturefunc = <function scrape_step at 0x7f472457eac0>
request = <FixtureRequest for <Function test_scenario_outline[NorthEastDerbyshireDistrictCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f4725624e30>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
.../uk_bin_collection/councils/NorthEastDerbyshireDistrictCouncil.py:41: in parse_data
    iframe_presense = WebDriverWait(driver, 30).until(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selenium.webdriver.support.wait.WebDriverWait (session="c55a8f46cd80540412e6cc504a8b99a6")>
method = <function presence_of_element_located.<locals>._predicate at 0x7f472396f600>
message = ''

    def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
        """Wait until the method returns a value that is not False.
    
        Calls the method provided with the driver as an argument until the
        return value does not evaluate to ``False``.
    
        Parameters:
        -----------
        method: callable(WebDriver)
            - A callable object that takes a WebDriver instance as an argument.
    
        message: str
            - Optional message for :exc:`TimeoutException`
    
        Return:
        -------
        object: T
            - The result of the last call to `method`
    
        Raises:
        -------
        TimeoutException
            - If 'method' does not return a truthy value within the WebDriverWait
            object's timeout
    
        Example:
        --------
        >>> from selenium.webdriver.common.by import By
        >>> from selenium.webdriver.support.ui import WebDriverWait
        >>> from selenium.webdriver.support import expected_conditions as EC
    
        # Wait until an element is visible on the page
        >>> wait = WebDriverWait(driver, 10)
        >>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
        >>> print(element.text)
        """
        screen = None
        stacktrace = None
    
        end_time = time.monotonic() + self._timeout
        while True:
            try:
                value = method(self._driver)
                if value:
                    return value
            except self._ignored_exceptions as exc:
                screen = getattr(exc, "screen", None)
                stacktrace = getattr(exc, "stacktrace", None)
            if time.monotonic() > end_time:
                break
            time.sleep(self._poll)
>       raise TimeoutException(message, screen, stacktrace)
E       selenium.common.exceptions.TimeoutException: Message: 
E       Stacktrace:
E       #0 0x55cd35638b6a <unknown>
E       #1 0x55cd3504ba32 <unknown>
E       #2 0x55cd350a0d66 <unknown>
E       #3 0x55cd350a0fa1 <unknown>
E       #4 0x55cd350ec144 <unknown>
E       #5 0x55cd350e9369 <unknown>
E       #6 0x55cd35092c0f <unknown>
E       #7 0x55cd350939d1 <unknown>
E       #8 0x55cd355fd6b9 <unknown>
E       #9 0x55cd356005c1 <unknown>
E       #10 0x55cd355e9e29 <unknown>
E       #11 0x55cd3560117e <unknown>
E       #12 0x55cd355d04b0 <unknown>
E       #13 0x55cd35625578 <unknown>
E       #14 0x55cd3562574b <unknown>
E       #15 0x55cd356371a3 <unknown>
E       #16 0x7fb96b75baa4 <unknown>
E       #17 0x7fb96b7e8a64 __clone

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../webdriver/support/wait.py:146: TimeoutException
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[SouthKestevenDistrictCouncil]

Flake rate in main: 100.00% (Passed 0 times, Failed 367 times)

Stack Traces | 30.3s run time
fixturefunc = <function scrape_step at 0x7f472457eac0>
request = <FixtureRequest for <Function test_scenario_outline[SouthKestevenDistrictCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7f4725624e30>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <SouthKestevenDistrictCouncil.CouncilClass object at 0x7f47239d2270>
page = ''
kwargs = {'council_module_str': 'SouthKestevenDistrictCouncil', 'dev_mode': False, 'headless': True, 'local_browser': False, ...}
user_postcode = 'PE68BL', collection_day = None

    def parse_data(self, page: str, **kwargs) -> dict:
        try:
            user_postcode = kwargs.get("postcode")
    
            # Validate postcode
            if not user_postcode:
                raise ValueError("Postcode is required for South Kesteven")
    
            # No WebDriver needed - using requests-based approach
    
            # Get collection day for regular bins
            collection_day = self.get_collection_day_from_postcode(None, user_postcode)
            if not collection_day:
>               raise ValueError(f"Could not determine collection day for postcode {user_postcode}")
E               ValueError: Could not determine collection day for postcode PE68BL

.../uk_bin_collection/councils/SouthKestevenDistrictCouncil.py:1146: ValueError
uk_bin_collection.tests.step_defs.test_validate_council::test_scenario_outline[SwaleBoroughCouncil]

Flake rate in main: 95.59% (Passed 16 times, Failed 347 times)

Stack Traces | 36s run time
fixturefunc = <function scrape_step at 0x7fcc54e0e160>
request = <FixtureRequest for <Function test_scenario_outline[SwaleBoroughCouncil]>>
kwargs = {'context': <test_validate_council.Context object at 0x7fcc697cbec0>, 'headless_mode': 'True', 'local_browser': 'False', 'selenium_url': 'http://localhost:4444'}

    def call_fixture_func(
        fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
    ) -> FixtureValue:
        if is_generator(fixturefunc):
            fixturefunc = cast(
                Callable[..., Generator[FixtureValue, None, None]], fixturefunc
            )
            generator = fixturefunc(**kwargs)
            try:
                fixture_result = next(generator)
            except StopIteration:
                raise ValueError(f"{request.fixturename} did not yield a value") from None
            finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
            request.addfinalizer(finalizer)
        else:
            fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
>           fixture_result = fixturefunc(**kwargs)

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../site-packages/_pytest/fixtures.py:898: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../tests/step_defs/test_validate_council.py:101: in scrape_step
    context.parse_result = CollectData.run()
uk_bin_collection/uk_bin_collection/collect_data.py:101: in run
    return self.client_code(
uk_bin_collection/uk_bin_collection/collect_data.py:121: in client_code
    return get_bin_data_class.template_method(address_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:61: in template_method
    bin_data_dict = self.get_and_parse_data(this_url, **kwargs)
uk_bin_collection/uk_bin_collection/get_bin_data.py:84: in get_and_parse_data
    bin_data_dict = self.parse_data("", url=address_url, **kwargs)
.../uk_bin_collection/councils/SwaleBoroughCouncil.py:58: in parse_data
    findAddress = WebDriverWait(driver, 10).until(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <selenium.webdriver.support.wait.WebDriverWait (session="47d09dd26eeb6113913b938158bfcefd")>
method = <function presence_of_element_located.<locals>._predicate at 0x7fcc5389fd80>
message = ''

    def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
        """Wait until the method returns a value that is not False.
    
        Calls the method provided with the driver as an argument until the
        return value does not evaluate to ``False``.
    
        Parameters:
        -----------
        method: callable(WebDriver)
            - A callable object that takes a WebDriver instance as an argument.
    
        message: str
            - Optional message for :exc:`TimeoutException`
    
        Return:
        -------
        object: T
            - The result of the last call to `method`
    
        Raises:
        -------
        TimeoutException
            - If 'method' does not return a truthy value within the WebDriverWait
            object's timeout
    
        Example:
        --------
        >>> from selenium.webdriver.common.by import By
        >>> from selenium.webdriver.support.ui import WebDriverWait
        >>> from selenium.webdriver.support import expected_conditions as EC
    
        # Wait until an element is visible on the page
        >>> wait = WebDriverWait(driver, 10)
        >>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
        >>> print(element.text)
        """
        screen = None
        stacktrace = None
    
        end_time = time.monotonic() + self._timeout
        while True:
            try:
                value = method(self._driver)
                if value:
                    return value
            except self._ignored_exceptions as exc:
                screen = getattr(exc, "screen", None)
                stacktrace = getattr(exc, "stacktrace", None)
            if time.monotonic() > end_time:
                break
            time.sleep(self._poll)
>       raise TimeoutException(message, screen, stacktrace)
E       selenium.common.exceptions.TimeoutException: Message: 
E       Stacktrace:
E       #0 0x55c7fd611b6a <unknown>
E       #1 0x55c7fd024a32 <unknown>
E       #2 0x55c7fd079d66 <unknown>
E       #3 0x55c7fd079fa1 <unknown>
E       #4 0x55c7fd0c5144 <unknown>
E       #5 0x55c7fd0c2369 <unknown>
E       #6 0x55c7fd06bc0f <unknown>
E       #7 0x55c7fd06c9d1 <unknown>
E       #8 0x55c7fd5d66b9 <unknown>
E       #9 0x55c7fd5d95c1 <unknown>
E       #10 0x55c7fd5c2e29 <unknown>
E       #11 0x55c7fd5da17e <unknown>
E       #12 0x55c7fd5a94b0 <unknown>
E       #13 0x55c7fd5fe578 <unknown>
E       #14 0x55c7fd5fe74b <unknown>
E       #15 0x55c7fd6101a3 <unknown>
E       #16 0x7fb9a4e67aa4 <unknown>
E       #17 0x7fb9a4ef4a64 __clone

../../../..../pypoetry/virtualenvs/uk-bin-collection-EwS6Gn8s-py3.12/lib/python3.12.../webdriver/support/wait.py:146: TimeoutException

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (10)
uk_bin_collection/uk_bin_collection/councils/RedcarandClevelandCouncil.py (1)

40-47: ⚠️ Potential issue | 🔴 Critical

Fix unsafe fallback for place_id selection (can crash or silently misroute request).

At Line 46, addresses[1] is dereferenced in the next() default expression, which raises IndexError when the API returns fewer than 2 addresses. Also, falling back to None can produce a misleading downstream request to /places/None/... instead of failing clearly at parse time.

Suggested fix
-        place_id = next(
-            (
-                item["place_id"]
-                for item in addresses
-                if item.get("name", "").startswith(user_paon)
-            ),
-            addresses[1]["place_id"] if addresses[1] else None,
-        )
+        place_id = next(
+            (
+                item.get("place_id")
+                for item in addresses
+                if item.get("name", "").startswith(user_paon)
+            ),
+            None,
+        )
+
+        if place_id is None:
+            if len(addresses) > 1 and addresses[1].get("place_id"):
+                place_id = addresses[1]["place_id"]
+            else:
+                raise ValueError(
+                    f"Unable to resolve place_id for postcode={user_postcode}, paon={user_paon}"
+                )

Based on learnings: In uk_bin_collection/**/*.py, when parsing council bin collection data, prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/RedcarandClevelandCouncil.py`
around lines 40 - 47, The current selection of place_id uses addresses[1] in the
next() default which can raise IndexError or silently pass None into downstream
requests; update the logic around place_id (the addresses list and the next(...)
call in RedcarandClevelandCouncil) to explicitly validate addresses length and
contents before choosing a fallback: if addresses is empty or has no matching
name, raise a descriptive exception (e.g., ValueError) rather than using
addresses[1] or None; ensure the code checks that items contain "place_id" and
that user_paon matching is correct, then assign place_id from the validated item
or fail fast with a clear error message.
uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py (2)

1-1: ⚠️ Potential issue | 🟡 Minor

Incorrect comment references Bromley Council.

This file is LondonBoroughRedbridge.py but the comment says "Bromley Council Bins Data" — appears to be a copy-paste artifact.

📝 Suggested fix
-# This script pulls (in one hit) the data from Bromley Council Bins Data
+# This script pulls (in one hit) the data from London Borough of Redbridge Bins Data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py` at
line 1, The file header comment incorrectly references "Bromley Council Bins
Data"; update the top-of-file comment in LondonBoroughRedbridge.py to accurately
describe the file (e.g., "This script pulls the data for London Borough of
Redbridge bin collections" or similar), removing the Bromley reference so the
comment matches the module purpose and filename.

120-124: ⚠️ Potential issue | 🟡 Minor

Consider explicit failure instead of silent continuation on date parse errors.

When ValueError is raised during date parsing, the error is printed but processing continues, potentially returning incomplete data without the caller knowing. Based on learnings, the project prefers explicit failures to ensure format changes are detected early.

If partial data is acceptable, consider at minimum logging at a warning level or collecting errors to report at the end.

🛡️ Option: Re-raise to fail explicitly
                             except ValueError as e:
-                                # Handle the case where the date format is invalid
-                                print(
-                                    f"Error parsing date '{date_string}' for {collection_type}: {e}"
-                                )
+                                # Fail explicitly on unexpected date format
+                                raise ValueError(
+                                    f"Failed to parse date '{date_string}' for {collection_type}"
+                                ) from e

Based on learnings: "In uk_bin_collection/**/*.py, when parsing council bin collection data, prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py`
around lines 120 - 124, Replace the silent print in the ValueError handler with
an explicit failure: in the LondonBoroughRedbridge code path where you catch
ValueError (the except ValueError as e block referencing date_string and
collection_type), re-raise a descriptive exception (or raise the original
exception) instead of printing so callers fail fast on unexpected date formats;
alternatively, if partial results are acceptable, change the print to a
processLogger.warning and collect the error for later reporting, but prefer
raising an exception to detect format changes early.
uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py (2)

54-59: ⚠️ Potential issue | 🔴 Critical

Exceptions are created but never raised — errors are silently ignored.

These Exception(...) calls instantiate exception objects but do not raise them. The code continues execution as if no error occurred, returning an empty data dict and masking failures.

Based on learnings: prefer explicit failures over silent defaults to ensure format changes are detected immediately.

🐛 Proposed fix to raise exceptions
             if len(matches) == 1:
                 street_url = matches[0]["href"]
                 full_url = base_url.rstrip("/") + street_url
                 bin_data = self.get_bin_data(full_url)

                 for k, v in bin_data.items():
                     for date in v:
                         dict_data = {
                             "type": k,
                             "collectionDate": datetime.strptime(
                                 date, "%Y-%m-%d"
                             ).strftime(date_format),
                         }
                         data["bins"].append(dict_data)

                 return data

             elif len(matches) > 1:
-                Exception("Multiple street URLs found. Please refine your search.")
+                raise ValueError("Multiple street URLs found. Please refine your search.")
             else:
-                Exception("Street URL not found.")
+                raise ValueError("Street URL not found.")
         else:
-            Exception("Failed to retrieve search results.")
+            raise ConnectionError(f"Failed to retrieve search results: HTTP {search_response.status_code}")

         return data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py`
around lines 54 - 59, The current error branches instantiate Exception objects
but don't raise them, so failures are swallowed; update the error handling in
NuneatonBedworthBoroughCouncil.py (the block that checks matches and the else
branch for failed search results) to raise the exceptions (e.g., raise
Exception("Multiple street URLs found. Please refine your search."), raise
Exception("Street URL not found."), and raise Exception("Failed to retrieve
search results.")) instead of just calling Exception(...), so the function fails
fast and surfaces format or retrieval errors; ensure these raises occur before
any return of the empty data dict.

938-946: ⚠️ Potential issue | 🔴 Critical

Multiple critical error-handling bugs: missing raise, potential KeyError, and UnboundLocalError.

  1. Line 938: If filename doesn't match a key in bin_data, a KeyError is raised with no meaningful message.
  2. Lines 941, 944: Exception(...) without raise — errors are silently ignored.
  3. Line 946: If the response status is not 200 or download_link is None, output is never assigned, causing UnboundLocalError.

Based on learnings: prefer explicit failures to ensure format changes are detected immediately.

🐛 Proposed fix
                 output = bin_data[filename]
+                return output
             else:
-                print(bin_day_response.content)
-                Exception("Bin data Download link not found.")
-
-        else:
-            Exception("Failed to retrieve bin data.")
-
-        return output
+                raise ValueError(f"Bin data download link not found on page: {url}")
+        else:
+            raise ConnectionError(f"Failed to retrieve bin data: HTTP {bin_day_response.status_code}")

Additionally, consider wrapping the dictionary lookup for a clearer error message:

if filename not in bin_data:
    raise ValueError(f"Unknown calendar filename '{filename}'. Expected one of: {list(bin_data.keys())}")
output = bin_data[filename]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py`
around lines 938 - 946, The current block silently swallows errors and can leave
`output` uninitialized; fix it by explicitly raising exceptions and validating
lookups: after getting `download_link` check response.status_code == 200 and
raise a descriptive exception (including `bin_day_response.content`) if not;
ensure `download_link` is not None and raise if missing; replace the bare dict
access `output = bin_data[filename]` with an explicit membership check (e.g. if
`filename not in bin_data: raise ValueError(f"Unknown calendar filename
{filename}; expected {list(bin_data.keys())}")`) then assign `output`; this
prevents KeyError and UnboundLocalError and makes failures explicit for
`filename`, `bin_data`, `bin_day_response`, `download_link`, and `output`.
uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py (1)

107-111: ⚠️ Potential issue | 🟡 Minor

Year assignment logic may incorrectly assign past dates.

The current logic only handles January correctly. If the current month is December and a parsed date is February-December, the code assigns the current year, placing the date in the past.

For example: In December 2026, parsing "Sat 15 Feb" yields February 2026 (past) instead of February 2027.

🐛 Suggested fix for year rollover
                             collection_date = datetime.strptime(collection_date_text, "%a %d %b")
-                            if collection_date.month == 1 and current_month != 1:
+                            # If parsed month is before current month, it's likely next year
+                            if collection_date.month < current_month:
                                 collection_date = collection_date.replace(year=current_year + 1)
                             else:
                                 collection_date = collection_date.replace(year=current_year)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py` around
lines 107 - 111, The year-assignment only handles January rollover and can yield
past dates; update the logic in BroxbourneCouncil.py after parsing
collection_date_text (where collection_date =
datetime.strptime(collection_date_text, "%a %d %b")) so the year is set to
current_year + 1 whenever the parsed month is less than the current_month (i.e.,
collection_date.month < current_month), otherwise use current_year; also
consider that if parsed month equals current_month you can keep current_year (or
optionally compare day to today to bump to next year), but the primary fix is
replacing the existing January-only branch with the generic month comparison
using collection_date.month, current_month, and current_year.
uk_bin_collection/uk_bin_collection/councils/PowysCouncil.py (1)

133-145: ⚠️ Potential issue | 🟠 Major

Do not silently skip garden-waste parse failures.

The bare except: continue drops data without surfacing format drift, which can hide scraper breakage.

Proposed fix
             for date in garden_waste_dates:
-                try:
-                    dict_data = {
-                        "type": "Garden Waste",
-                        "collectionDate": datetime.strptime(
-                            remove_ordinal_indicator_from_date_string(
-                                date.split(" (")[0]
-                            ),
-                            "%A %d %B %Y",
-                        ).strftime(date_format),
-                    }
-                    data["bins"].append(dict_data)
-                except:
-                    continue
+                cleaned_date = remove_ordinal_indicator_from_date_string(
+                    date.split(" (")[0]
+                )
+                try:
+                    parsed_date = datetime.strptime(cleaned_date, "%A %d %B %Y")
+                except ValueError as exc:
+                    raise ValueError(
+                        f"Unexpected garden waste date format: '{date}'"
+                    ) from exc
+
+                data["bins"].append(
+                    {
+                        "type": "Garden Waste",
+                        "collectionDate": parsed_date.strftime(date_format),
+                    }
+                )

Based on learnings: In uk_bin_collection/**/*.py, when parsing council bin collection data, prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/PowysCouncil.py` around lines
133 - 145, The try/except block around building dict_data in PowysCouncil should
not silently swallow parse errors; replace the bare except with catching the
specific parsing error (ValueError) and re-raise a descriptive exception (or at
least log an error) that includes the raw date string and context so format
drift is visible. Specifically, change the code around
remove_ordinal_indicator_from_date_string(...) and datetime.strptime(..., "%A %d
%B %Y") that builds dict_data (and references date_format and data["bins"]) to
catch ValueError as e and raise a new ValueError (or logging + raise) that
mentions "PowysCouncil", the offending date, and the original exception (use
"from e") instead of continuing silently.
uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py (1)

80-87: ⚠️ Potential issue | 🟠 Major

Don’t return synthetic "Error" bins on parser failures.

Lines 80-87 convert hard scraping failures into normal-looking data, which can mask upstream breakages and bad collections output. Prefer raising an exception after logging context.

💡 Suggested fix
-        except Exception as e:
-            import traceback
-
-            error_message = f"Error fetching/parsing data for Eastleigh: {str(e)}\n{traceback.format_exc()}"
-            print(error_message)
-            # Use the correct date format for the error fallback
-            today = datetime.now().strftime("%d/%m/%Y")
-            return {"bins": [{"type": "Error", "collectionDate": today}]}
+        except Exception as e:
+            raise RuntimeError(
+                f"Error fetching/parsing data for Eastleigh: {e}"
+            ) from e

Based on learnings: In uk_bin_collection/**/*.py, prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py`
around lines 80 - 87, The except block in EastleighBoroughCouncil.py currently
catches exceptions and returns a synthetic bin entry (the "Error" bin); instead,
log the error context (include traceback) and then re-raise the exception (or
raise a descriptive custom exception) so failures surface to callers; update the
except Exception as e handler in the EastleighBoroughCouncil class/method where
the try/except appears to stop returning {"bins":[{"type":"Error",...}]} and
instead log the error (avoid print if a logger is available) and raise the
original or a new exception after logging.
uk_bin_collection/uk_bin_collection/councils/HarboroughDistrictCouncil.py (1)

60-87: ⚠️ Potential issue | 🟠 Major

Stop swallowing row-parse failures.

Line 74 and Lines 86-87 currently drop unexpected row formats/date errors and keep going. That can turn a Harborough site change into partial or empty data with no signal. Raise with the offending row text instead of continue.

🧪 Suggested fail-fast parsing
         lis = bin_collection.find_all("li")
         for li in lis:
-            try:
+            row_text = li.get_text(" ", strip=True)
+            try:
                 # Try the new format first (with span.pull-right)
                 date_span = li.find("span", {"class": "pull-right"})
                 if date_span:
                     date_text = date_span.text.strip()
                     date = datetime.strptime(date_text, "%d %B %Y").strftime("%d/%m/%Y")
                     # Extract bin type from the text before the span
-                    bin_type = li.text.replace(date_text, "").strip()
+                    bin_type = row_text.replace(date_text, "").strip()
                 else:
                     # Fall back to old format (regex match)
-                    split = re.match(r"(.+)\s(\d{1,2} \w+ \d{4})$", li.text)
+                    split = re.match(r"(.+)\s(\d{1,2} \w+ \d{4})$", row_text)
                     if not split:
-                        continue
+                        raise ValueError(f"Unexpected Harborough row format: {row_text!r}")
                     bin_type = split.group(1).strip()
                     date = datetime.strptime(
                         split.group(2),
                         "%d %B %Y",
                     ).strftime("%d/%m/%Y")
@@
-            except Exception:
-                continue
+            except Exception as exc:
+                raise ValueError(f"Failed to parse Harborough row: {row_text!r}") from exc

Based on learnings: in uk_bin_collection/**/*.py, prefer explicit failures over silent defaults when parsing council data so format changes surface immediately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/HarboroughDistrictCouncil.py`
around lines 60 - 87, In the for-loop over lis (for li in lis) replace the
silent continues on parse failures with an explicit raise that includes the
offending li text so failures surface; specifically, when date_span parsing or
the regex match fails (the branches using date_span, date_text, split, bin_type,
date, dict_data and the append to bindata["bins"]) do not catch Exception and
continue — instead let parsing errors bubble or raise a ValueError/RuntimeError
containing li.text (or the problematic date_text) so the caller/tester can see
which row failed; remove the broad try/except that swallows errors and only
catch/rethrow with the li content if you need to add context.
uk_bin_collection/uk_bin_collection/councils/BathAndNorthEastSomersetCouncil.py (1)

60-75: ⚠️ Potential issue | 🟠 Major

Add timeout and status checks before iterating the BathNES payload.

response.text == "" is insufficient protection for this endpoint. A 4xx/5xx response, HTML error page, or non-list JSON payload will currently fail with a generic error. The request also has no timeout, and the response is not validated with raise_for_status() like nearly all other council integrations in the codebase.

🛠 Suggested hardening
         response = session.get(
             f"https://api.bathnes.gov.uk/webapi/api/BinsAPI/v2/BartecFeaturesandSchedules/CollectionSummary/{user_uprn}",
             headers=headers,
+            timeout=30,
         )
-        if response.text == "":
+        response.raise_for_status()
+        if not response.text.strip():
             raise ValueError(
                 "Error parsing data. Please check the provided UPRN. "
                 "If this error continues please open an issue on GitHub."
             )
-        json_data = json.loads(response.text)
+        try:
+            json_data = json.loads(response.text)
+        except json.JSONDecodeError as exc:
+            raise ValueError(
+                f"Unexpected non-JSON BathNES response for UPRN {user_uprn}"
+            ) from exc
+        if not isinstance(json_data, list):
+            raise ValueError(
+                f"Unexpected BathNES collection payload for UPRN {user_uprn}"
+            )

Per the project's established pattern, explicit failures on format changes prevent silent data loss when APIs change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/BathAndNorthEastSomersetCouncil.py`
around lines 60 - 75, The current GET to the BathNES CollectionSummary endpoint
should be hardened: call session.get with a sensible timeout and then call
response.raise_for_status() to fail on 4xx/5xx; validate the Content-Type and
handle JSONDecodeError when calling json.loads(response.text); ensure the parsed
json_data is a list before iterating (raise a clear ValueError if it's not) and
preserve the existing error message but include context (e.g., endpoint/UPrn);
reference the session.get call, response, json.loads, json_data and the loop
that uses datetime.fromisoformat(collection["nextCollectionDate"]) so you update
those spots.
🟠 Major comments (21)
uk_bin_collection/uk_bin_collection/councils/HinckleyandBosworthBoroughCouncil.py-31-43 (1)

31-43: ⚠️ Potential issue | 🟠 Major

Fail fast on malformed ICS events instead of silently dropping data.

On Line 32, events without summary/start are silently ignored, and on Lines 33-38 an empty token (e.g., trailing comma) can produce a blank type. This can mask upstream format changes and return incomplete results.

💡 Suggested fix (explicit validation)
-        for event in sorted(upcoming_events, key=lambda e: e.start):
-            if event.summary and event.start:
-                collections = event.summary.split(",")
-                for collection in collections:
-                    if collection.strip() == "bin collection":
-                        collection = "food waste caddy"
-                    collection = collection.strip().replace(" collection", "")
-                    bindata["bins"].append(
-                        {
-                            "type": collection,
-                            "collectionDate": event.start.date().strftime(date_format),
-                        }
-                    )
+        for event in sorted(upcoming_events, key=lambda e: e.start):
+            if not event.summary or not event.start:
+                raise ValueError("Unexpected ICS event format: missing summary or start")
+
+            collections = [c.strip() for c in event.summary.split(",")]
+            if any(not c for c in collections):
+                raise ValueError(f"Unexpected ICS event summary format: {event.summary!r}")
+
+            for collection in collections:
+                if collection == "bin collection":
+                    collection = "food waste caddy"
+                collection = collection.replace(" collection", "")
+                bindata["bins"].append(
+                    {
+                        "type": collection,
+                        "collectionDate": event.start.date().strftime(date_format),
+                    }
+                )

Based on learnings: In uk_bin_collection/**/*.py, when parsing council bin collection data, prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/HinckleyandBosworthBoroughCouncil.py`
around lines 31 - 43, Validate each ICS event before processing in the loop over
upcoming_events: if an event lacks event.summary or event.start raise an
exception (e.g., ValueError) with details about the offending event so we fail
fast instead of silently ignoring it; when splitting event.summary into
collections filter out empty tokens (after .strip()) and if any token becomes
empty or unexpected after replacing " collection" (i.e., results in an empty
type) raise an exception mentioning the raw event and date_format rather than
appending a blank entry to bindata["bins"]; update the logic around
upcoming_events, event.summary, event.start, and bindata["bins"] accordingly so
malformed ICS rows surface as errors.
uk_bin_collection/uk_bin_collection/councils/HinckleyandBosworthBoroughCouncil.py-25-26 (1)

25-26: ⚠️ Potential issue | 🟠 Major

Replace wildcard import with explicit imports to resolve F405 linting errors.

Lines 19, 25, 26, 41, and 46 use symbols from the wildcard import that Ruff flags as undefined (F405). Make imports explicit:

Suggested fix
+from datetime import datetime, timedelta
 from icalevents.icalevents import events
 
-from uk_bin_collection.uk_bin_collection.common import *
+from uk_bin_collection.uk_bin_collection.common import check_uprn, date_format
 from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass

This eliminates F405 errors and removes fragility from implicit transitive coupling through wildcard imports.

Also applies to: 41-41, 46-46

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/HinckleyandBosworthBoroughCouncil.py`
around lines 25 - 26, The file currently relies on a wildcard import which
causes F405 lint errors; replace the star import with explicit imports for the
exact symbols used (e.g., import datetime and timedelta for the expressions
datetime.now() and timedelta(...), plus the other named symbols referenced on
lines 19, 41 and 46). Update the top-level import statement to list those
specific symbols (include datetime, timedelta and any
classes/functions/constants referenced in this module) instead of using `from
... import *` so Ruff no longer flags undefined names.
uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py-22-22 (1)

22-22: ⚠️ Potential issue | 🟠 Major

Replace wildcard import with explicit symbols to resolve Ruff F405.

The wildcard import at line 5 causes Ruff to report unresolved symbols when create_webdriver is used at line 22. The file also uses date_format from the common module at lines 62, 86, and 106. Replace the wildcard import with both explicit symbols:

Suggested fix
-from uk_bin_collection.uk_bin_collection.common import *
+from uk_bin_collection.uk_bin_collection.common import create_webdriver, date_format
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py` at line
22, Replace the wildcard import from the common module with explicit imports for
the symbols actually used: import create_webdriver and date_format instead of
"from ...common import *"; update the top of WakefieldCityCouncil.py to import
create_webdriver (used where driver = create_webdriver(...)) and date_format
(used at the calls around lines referenced) so Ruff F405 is resolved and only
the required symbols are imported.
uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py-27-27 (1)

27-27: ⚠️ Potential issue | 🟠 Major

Replace wildcard import with explicit imports to avoid Ruff F405 warnings.

Line 6 uses a wildcard import that makes the code lint-fragile. Explicitly importing only what is used (create_webdriver, check_uprn, date_format) prevents undefined name errors and makes dependencies clear.

💡 Suggested fix
+from datetime import datetime
 from bs4 import BeautifulSoup
 from selenium.webdriver.common.by import By
 from selenium.webdriver.support import expected_conditions as EC
 from selenium.webdriver.support.ui import Select, WebDriverWait

-from uk_bin_collection.uk_bin_collection.common import *
+from uk_bin_collection.uk_bin_collection.common import (
+    check_uprn,
+    create_webdriver,
+    date_format,
+)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py` at
line 27, Replace the wildcard import on line 6 with explicit imports to avoid
Ruff F405: instead of "from ... import *" import only the used symbols
(create_webdriver, check_uprn, date_format). Update the import statement that
currently provides those utilities so it explicitly lists create_webdriver,
check_uprn, date_format; this will remove the undefined-name/lint warnings and
make dependencies for EastleighBoroughCouncil clearer.
uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py-35-35 (1)

35-35: ⚠️ Potential issue | 🟠 Major

Import create_webdriver and remove_ordinal_indicator_from_date_string explicitly instead of relying on * import.

Line 35 calls create_webdriver (and line 68 calls remove_ordinal_indicator_from_date_string), but both are only resolved via from uk_bin_collection.uk_bin_collection.common import *, which is what Ruff F405 is flagging and can block CI.

💡 Suggested fix
-from uk_bin_collection.uk_bin_collection.common import *
+from uk_bin_collection.uk_bin_collection.common import (
+    create_webdriver,
+    remove_ordinal_indicator_from_date_string,
+)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py` at
line 35, Replace the wildcard import that brings in common helpers with explicit
imports: import create_webdriver and remove_ordinal_indicator_from_date_string
from the common module instead of using from ...common import *; update the
import statement used by BromleyBoroughCouncil.py (the module that calls
create_webdriver and remove_ordinal_indicator_from_date_string) to explicitly
import those two symbols so Ruff F405 is resolved and CI no longer flags the
star import.
uk_bin_collection/uk_bin_collection/councils/HarboroughDistrictCouncil.py-29-37 (1)

29-37: ⚠️ Potential issue | 🟠 Major

Add explicit timeouts to the FCC session requests.

Lines 31–37 add two outbound HTTP calls without timeouts. This can cause the scrape to hang indefinitely on a stalled endpoint. Both session.get() and session.post() should include explicit timeout parameters.

Note: The verify=False bypass and the module-level urllib3.disable_warnings() were intentionally added in #1831 to resolve connectivity issues with this endpoint and should remain.

🔧 Add timeouts
         timeout = (10, 30)
         session = requests.session()
         response = session.get(
-            URI1, verify=False
+            URI1, verify=False, timeout=timeout
         )  # Initialize session state (cookies) required by URI2
         response.raise_for_status()  # Validate session initialization
 
         params = {"Uprn": user_uprn}
-        response = session.post(URI2, data=params, verify=False)
+        response = session.post(URI2, data=params, verify=False, timeout=timeout)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/HarboroughDistrictCouncil.py`
around lines 29 - 37, The two outbound requests using session.get(URI1,
verify=False) and session.post(URI2, data=params, verify=False) in
HarboroughDistrictCouncil (the block that initializes session state and posts
the Uprn) must include explicit timeout values to avoid hanging; update the
session.get and session.post calls to add a timeout argument (e.g., timeout=10
or a connect/read tuple like timeout=(5,30)) while keeping verify=False and the
existing session usage intact so session state (cookies) remains initialized
before the post.
uk_bin_collection/uk_bin_collection/councils/CumberlandCouncil.py-34-34 (1)

34-34: ⚠️ Potential issue | 🟠 Major

Filter list items to those with expected structure and add explicit validation.

The current code assumes every <li> element has the expected span/time structure. An unrelated <li> will cause cryptic AttributeError or TypeError instead of a diagnostic error.

Filter for items with the required span class and validate structure before accessing attributes:

🔧 Proposed fix
-        lis = content_region.find_all("li")
-        for li in lis:
+        rows = [
+            li
+            for li in content_region.find_all("li")
+            if li.find("span", class_="waste-collection__day--day")
+        ]
+        if not rows:
+            raise ValueError("No waste collection rows found in Cumberland response")
+
+        for li in rows:
             collection_day = li.find("span", class_="waste-collection__day--day")
             collection_type_str = li.find("span", class_="waste-collection__day--type")
-
-            collection_date = collection_day.find("time")["datetime"]
+            time_tag = collection_day.find("time") if collection_day else None
+            if not collection_type_str or not time_tag or "datetime" not in time_tag.attrs:
+                raise ValueError("Unexpected waste collection row structure in Cumberland response")
+            collection_date = time_tag["datetime"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/CumberlandCouncil.py` at line
34, The code currently sets lis = content_region.find_all("li") and then assumes
each li has the expected span/time structure; change this to only collect list
items that contain the required children (e.g., a span with the expected class
and a time element) by replacing the naive find_all with a filtered list
comprehension or generator that checks li.find("span", class_=...) and
li.find("time") exist, then add explicit validation before accessing attributes
in the parsing function (raise or log a clear ValueError mentioning the
offending li when structure is missing); reference the variable lis and
content_region.find_all("li") and update the parsing code that consumes lis to
handle missing fields gracefully.
uk_bin_collection/uk_bin_collection/councils/CumberlandCouncil.py-1-1 (1)

1-1: ⚠️ Potential issue | 🟠 Major

Replace star import with explicit imports to resolve Ruff F405 flags.

This parser uses datetime.strptime() at lines 43 and 53, and date_format at lines 47 and 53, along with check_uprn() at line 19—all currently sourced via the star import from common. The imported date at line 1 is unused. Ruff correctly flags the undefined datetime and date_format as F405.

Proposed fix
-from datetime import date
+from datetime import datetime

 import requests
 from bs4 import BeautifulSoup

-from uk_bin_collection.uk_bin_collection.common import *
+from uk_bin_collection.uk_bin_collection.common import check_uprn, date_format
 from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/CumberlandCouncil.py` at line 1,
Remove the unused "from datetime import date" and the star import from common;
instead import only the symbols you use: add "from datetime import datetime" to
allow calls to datetime.strptime, and import date_format and check_uprn from
common (e.g., from common import date_format, check_uprn) so references to
date_format (lines ~47/53) and check_uprn (line ~19) are defined and Ruff F405
warnings are resolved.
pyproject.toml-27-27 (1)

27-27: ⚠️ Potential issue | 🟠 Major

Regenerate and commit poetry.lock after this dependency-section migration.

The pipeline is already failing because the lock file no longer matches pyproject.toml after Line 27’s section change.

✅ Required fix
poetry lock --no-update
git add poetry.lock
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` at line 27, The CI is failing because poetry.lock is out of
sync with the pyproject.toml change to the [tool.poetry.group.dev.dependencies]
section; regenerate the lockfile by running the lock command (e.g., use poetry
lock --no-update), then stage and commit the updated poetry.lock so it matches
pyproject.toml and fixes the pipeline.
uk_bin_collection/uk_bin_collection/councils/MertonCouncil.py-115-127 (1)

115-127: ⚠️ Potential issue | 🟠 Major

Add explicit DOM guards before dereferencing parsed nodes.

Line 118 and Line 126 can crash with AttributeError if the council DOM shifts. Raise explicit parser errors with clear messages instead.

🔧 Suggested fix
         govuk_grid_column_two_thirds = soup.find(
             "div", class_="govuk-grid-column-two-thirds"
         )
+        if govuk_grid_column_two_thirds is None:
+            raise ValueError("Expected Merton container 'govuk-grid-column-two-thirds' not found")
+
         waste_service_grids = govuk_grid_column_two_thirds.find_all(
             "div", class_="waste-service-grid"
         )
+        if not waste_service_grids:
+            raise ValueError("No waste service grids found in Merton response")
 
         for waste_service_grid in waste_service_grids:
 
             h3 = waste_service_grid.find("h3", class_="waste-service-name")
+            if h3 is None:
+                raise ValueError("Missing waste service name header in Merton response")
 
             bin_type = h3.get_text().strip()

Based on learnings: prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/MertonCouncil.py` around lines
115 - 127, The code dereferences parsed nodes without guarding for missing
elements; add explicit DOM guards and raise clear parser exceptions when
expectations fail: check that govuk_grid_column_two_thirds (result of
soup.find("div", class_="govuk-grid-column-two-thirds")) is not None before
calling .find_all and raise a ParserError (or ValueError) with a descriptive
message if missing; similarly, inside the loop ensure
waste_service_grid.find("h3", class_="waste-service-name") returns a non-None h3
before calling .get_text(), and raise a parser error naming the missing element
(e.g., "missing waste-service-name h3 in waste_service_grid") so downstream
callers fail fast and with useful diagnostics.
uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py-33-35 (1)

33-35: ⚠️ Potential issue | 🟠 Major

Do not hardcode the subscription key in source code.

Line 34 embeds an API key directly in code. Move it to runtime configuration (env var/secret store) and fail explicitly if it is missing.

🔧 Suggested fix
+import os
...
-            headers = {
-                "ocp-apim-subscription-key": "ad8dd80444fe45fcad376f82cf9a5ab4",
+            subscription_key = os.environ["LEEDS_WASTE_API_SUBSCRIPTION_KEY"]
+            headers = {
+                "ocp-apim-subscription-key": subscription_key,
                 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py` around
lines 33 - 35, The subscription key is hardcoded in the headers dict inside
LeedsCityCouncil.py (the "ocp-apim-subscription-key" value); change this to read
from a runtime configuration (e.g., an environment variable like
LEEDS_API_SUBSCRIPTION_KEY or a secrets store) when building headers, and make
the code raise an explicit error (or exit) if that variable is missing or empty
so the process fails fast rather than using a baked-in secret.
uk_bin_collection/tests/input.json-1851-1859 (1)

1851-1859: ⚠️ Potential issue | 🟠 Major

NorthWarwickshireBoroughCouncil LAD code appears wrong.

Line 1858 sets LAD24CD to E07000220, which is already used by Rugby. North Warwickshire should be E07000218.

🔧 Suggested fix
-        "LAD24CD": "E07000220"
+        "LAD24CD": "E07000218"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/tests/input.json` around lines 1851 - 1859, The LAD24CD for
the NorthWarwickshireBoroughCouncil entry is incorrect (currently "E07000220"
which is Rugby); update the NorthWarwickshireBoroughCouncil object's LAD24CD
value to the correct code "E07000218" so it no longer conflicts with Rugby,
ensuring you edit the "NorthWarwickshireBoroughCouncil" JSON object and replace
the existing LAD24CD string with "E07000218".
uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py-49-56 (1)

49-56: ⚠️ Potential issue | 🟠 Major

Do not swallow page-load/parser failures in this block.

Line 54 catches Exception and only prints, then execution continues. Fail fast here with a typed exception so site-format changes are surfaced immediately.

🔧 Suggested fix
+from selenium.common.exceptions import TimeoutException
...
-            except Exception:
-                print("Page failed to load. Probably due to Cloudflare robot check!")
+            except TimeoutException as exc:
+                raise RuntimeError(
+                    "Swale page did not expose postcode field; selector/layout or bot protection likely changed."
+                ) from exc

Based on learnings: prefer explicit failures (raise exceptions on unexpected formats) over silent defaults or swallowed errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py` around
lines 49 - 56, The current try/except around WebDriverWait and
inputElement_postcode.send_keys swallows all Exceptions and only prints a
message; change this to catch a specific exception (e.g.,
selenium.common.exceptions.TimeoutException or NoSuchElementException) and
re-raise or raise a new descriptive RuntimeError so failures surface
immediately; update the except block in the block containing
WebDriverWait(driver, 10).until(...), inputElement_postcode and send_keys to
stop swallowing errors — log if needed, then raise a typed exception with
context (include user_postcode and the element id "q499089_q1") so site-format
changes are not silently ignored.
uk_bin_collection/uk_bin_collection/councils/NewhamCouncil.py-22-23 (1)

22-23: ⚠️ Potential issue | 🟠 Major

Add timeout and HTTP status checking to the request.

Line 22 makes an HTTP request without timeout or status validation, allowing silent failures and potential hangs. While verify=False is used across multiple council parsers in the codebase (likely for SSL cert workarounds), the request must call raise_for_status() after the request to enforce explicit failure on HTTP errors.

🔧 Suggested fix
-        page = requests.get(url, verify=False)
+        page = requests.get(url, verify=False, timeout=30)
+        page.raise_for_status()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/NewhamCouncil.py` around lines
22 - 23, The HTTP request in NewhamCouncil.py uses requests.get(url,
verify=False) without timeout or status checking; update the call to include a
sensible timeout and immediately call response.raise_for_status() on the
returned object (the variable currently named page) before parsing with
BeautifulSoup to ensure HTTP errors raise exceptions; if there is existing
exception handling around this code, keep it and ensure it catches
requests.RequestException to handle timeouts and HTTP errors from
raise_for_status().
uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py-41-45 (1)

41-45: ⚠️ Potential issue | 🟠 Major

Harden this API request and remove payload logging.

Line 41 has no timeout/status check, Line 43 prints raw response content (potentially sensitive data), and Line 34 exposes the API key in source code. Use explicit error handling with raise_for_status() and response.json(), and move credentials to environment variables.

🔧 Suggested fix
-            response = requests.get(URI, params=params, headers=headers)
-
-            print(response.content)
-
-            collections = json.loads(response.content)
+            response = requests.get(URI, params=params, headers=headers, timeout=30)
+            response.raise_for_status()
+            collections = response.json()

Also move the subscription key from hardcoded string to an environment variable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py` around
lines 41 - 45, The API call in LeedsCityCouncil (around the requests.get call
that uses URI, params, headers and the hardcoded subscription key) must be
hardened: remove the debug print(response.content); replace
json.loads(response.content) with response.json(); call
response.raise_for_status() after the get and wrap the request in a try/except
catching requests.exceptions.RequestException to surface/log errors; add an
explicit timeout argument to requests.get; and move the hardcoded subscription
key into an environment variable (read it via os.environ) and inject it into
headers rather than keeping the literal string in source.
uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py-35-60 (1)

35-60: ⚠️ Potential issue | 🟠 Major

Don't treat an empty scrape as success.

If the selector drifts and find_all("a") yields nothing, this parser currently returns {"bins": []} as if it succeeded. Please raise once no collections were extracted.

Suggested change
             dict_data = {
                 "type": collection_type,
                 "collectionDate": collection_day,
             }
             bindata["bins"].append(dict_data)
 
+        if not bindata["bins"]:
+            raise RuntimeError("LBHF: no bin data found in page HTML.")
+
         bindata["bins"].sort(
             key=lambda x: datetime.strptime(x.get("collectionDate"), "%d/%m/%Y")
         )

Based on learnings, prefer explicit failures over silent defaults or swallowed errors when parsing council bin collection data so format changes surface immediately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py`
around lines 35 - 60, The parser currently treats an empty result from
ol.find_all("a") as success; after computing bin_collections (the result of
ol.find_all("a") in the function that builds bindata) add an explicit failure:
if bin_collections is empty or None, raise an exception (e.g., ValueError or a
custom ParseError) with a clear message that no bin collections were extracted
so the caller/test harness can surface selector drift; do this check before the
for loop that populates bindata["bins"] and only proceed to sort and return when
collections were found.
uk_bin_collection/uk_bin_collection/councils/LondonBoroughOfRichmondUponThames.py-25-34 (1)

25-34: ⚠️ Potential issue | 🟠 Major

Use the parsed PID instead of a raw substring check.

This branch currently treats any URL containing pid= as authoritative, even when the value is empty or malformed, so a bad base_url can ignore a valid pid/paon fallback and fetch the wrong page.

Suggested change
-        if "pid=" in (base_url or ""):
+        if pid_from_url:
             target_url = base_url
         elif pid_arg or pid_from_paon:
             pid = pid_arg or pid_from_paon
             sep = "&" if "?" in (base_url or "") else "?"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/LondonBoroughOfRichmondUponThames.py`
around lines 25 - 34, The branch that currently checks "pid=" in base_url should
instead use the parsed pid_from_url (returned by _pid_from_url) to determine if
a valid PID is present; replace the raw substring check with a truthy check of
pid_from_url and, if present and valid, use base_url as target_url, otherwise
fall back to pid_arg or pid_from_paon (from _pid_from_paon) to construct the
target_url with the proper separator logic; ensure references to pid_from_url,
pid_from_paon, pid_arg, _pid_from_url, _pid_from_paon, and target_url are used
so the code prefers a parsed PID over a mere "pid=" substring.
uk_bin_collection/uk_bin_collection/councils/NorthWarwickshireBoroughCouncil.py-162-195 (1)

162-195: ⚠️ Potential issue | 🟠 Major

Raise if all lookup calls produce no bins.

Right now auth/API regressions can fall through every stream block and return an apparently valid empty payload. Please fail fast once aggregation produced nothing.

Suggested change
         if garden:
             for key, item in garden.items():
                 dict_data = {
                     "type": item["JobName"].strip(),
                     "collectionDate": item["Date"],
                 }
                 bindata["bins"].append(dict_data)
 
+        if not bindata["bins"]:
+            raise RuntimeError("North Warwickshire: no bin data returned from API.")
+
         bindata["bins"].sort(
             key=lambda x: datetime.strptime(x.get("collectionDate"), date_format)
         )

Based on learnings, prefer explicit failures over silent defaults or swallowed errors when parsing council bin collection data so format changes surface immediately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/NorthWarwickshireBoroughCouncil.py`
around lines 162 - 195, After aggregating into bindata['bins'] (from food_waste,
refuse, recycling, garden), add a fail-fast check immediately before the
sort/return: if bindata["bins"] is empty, raise a clear exception (e.g.,
ValueError or a CouncilDataError) with a message indicating no bins were
returned for NorthWarwickshireBoroughCouncil; keep the check between the
aggregation loop and the existing bindata["bins"].sort(...) so missing data
fails fast and prevents returning an empty payload.
uk_bin_collection/uk_bin_collection/councils/NorthWarwickshireBoroughCouncil.py-35-35 (1)

35-35: ⚠️ Potential issue | 🟠 Major

Add timeouts to every Achieve call.

Lines 35, 60, 102, 120, 138, and 156 all make network requests via the requests library without specifying a timeout. Without explicit timeouts, a slow or unresponsive upstream server will cause the entire scrape to hang indefinitely. Add timeout parameters (e.g., timeout=10) to each s.get() and s.post() call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/NorthWarwickshireBoroughCouncil.py`
at line 35, Network requests in NorthWarwickshireBoroughCouncil.py use
requests.Session methods without timeouts (e.g., the call r = s.get(SESSION_URL)
and the other s.get()/s.post() calls at the same file); update every s.get(...)
and s.post(...) invocation in this module to include a timeout parameter (for
example timeout=10) so each network call (including the one using SESSION_URL
and the calls around lines 60, 102, 120, 138, 156) becomes s.get(...,
timeout=10) or s.post(..., timeout=10).
uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py-28-32 (1)

28-32: ⚠️ Potential issue | 🟠 Major

Add a timeout to the LBHF request.

session.get() lacks a timeout parameter, risking indefinite hangs that block the entire scrape operation.

Suggested change
-        response = session.get(URI)
+        response = session.get(URI, timeout=30)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py`
around lines 28 - 32, The session.get(URI) call in
LondonBoroughHammersmithandFulham (where session is created and headers set) has
no timeout and can hang; update the call to pass a timeout parameter (e.g.,
session.get(URI, timeout=10) or use an existing DEFAULT_TIMEOUT constant if one
exists) so the request will fail fast, and keep the existing
response.raise_for_status() logic unchanged; ensure any surrounding exception
handling will catch requests.exceptions.Timeout if needed.
uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py-6-7 (1)

6-7: ⚠️ Potential issue | 🟠 Major

Replace the star import with explicit imports before merging.

The star import at line 6 causes F403/F405 lint violations in Ruff and obscures the module's dependencies. The file uses four symbols from common: check_postcode, date_format, days_of_week, and get_next_day_of_week. Replace with explicit imports to resolve linting failures.

Suggested change
-from uk_bin_collection.uk_bin_collection.common import *
+from uk_bin_collection.uk_bin_collection.common import (
+    check_postcode,
+    date_format,
+    days_of_week,
+    get_next_day_of_week,
+)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py`
around lines 6 - 7, Replace the star import from
uk_bin_collection.uk_bin_collection.common with explicit imports to fix lint
F403/F405: import check_postcode, date_format, days_of_week, and
get_next_day_of_week instead of using *. Update the top of
LondonBoroughHammersmithandFulham.py where common is imported and keep the
existing import of AbstractGetBinDataClass unchanged so the class and functions
used in this file resolve without wildcard imports.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2d6c013-1c4a-4248-aabd-c391b68dad66

📥 Commits

Reviewing files that changed from the base of the PR and between f6cbad7 and 026d167.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (30)
  • .github/workflows/ha_compatibility_test.yml
  • .github/workflows/release.yml
  • pyproject.toml
  • uk_bin_collection/tests/input.json
  • uk_bin_collection/uk_bin_collection/councils/BarkingDagenham.py
  • uk_bin_collection/uk_bin_collection/councils/BathAndNorthEastSomersetCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/BroxtoweBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/CumberlandCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/HarboroughDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/HinckleyandBosworthBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/LondonBoroughHammersmithandFulham.py
  • uk_bin_collection/uk_bin_collection/councils/LondonBoroughHavering.py
  • uk_bin_collection/uk_bin_collection/councils/LondonBoroughOfRichmondUponThames.py
  • uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py
  • uk_bin_collection/uk_bin_collection/councils/MertonCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/MidSuffolkDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/MidlothianCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/NewhamCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/NorthEastDerbyshireDistrictCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/NorthWarwickshireBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/PowysCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/RedcarandClevelandCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py
  • uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py
  • wiki/Councils.md

Comment on lines +24 to +26
URI = "https://api-prd.havering.gov.uk"
endpoint = f"{URI}/whitespace/GetCollectionByUprnAndDate"
subscription_key = "2ea6a75f9ea34bb58d299a0c9f84e72e"
subscription_key = "545bcf53c9094dfd980dd9da72b0514d"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove the hardcoded subscription key and fail fast on missing secret.

Line 26 introduces a plaintext API credential in source control, which is a release-blocking security issue. Load it from environment/secret storage and raise an explicit error when it is absent.

🔐 Proposed fix
+import os
 import time
 
 import requests
 from bs4 import BeautifulSoup
@@
-        URI = "https://api-prd.havering.gov.uk"
+        URI = "https://api-prd.havering.gov.uk"
         endpoint = f"{URI}/whitespace/GetCollectionByUprnAndDate"
-        subscription_key = "545bcf53c9094dfd980dd9da72b0514d"
+        subscription_key = os.getenv("HAVERING_API_SUBSCRIPTION_KEY")
+        if not subscription_key:
+            raise RuntimeError("Missing HAVERING_API_SUBSCRIPTION_KEY")

Based on learnings: In uk_bin_collection/**/*.py, prefer explicit failures over silent behavior so upstream changes/issues are detected immediately.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
URI = "https://api-prd.havering.gov.uk"
endpoint = f"{URI}/whitespace/GetCollectionByUprnAndDate"
subscription_key = "2ea6a75f9ea34bb58d299a0c9f84e72e"
subscription_key = "545bcf53c9094dfd980dd9da72b0514d"
URI = "https://api-prd.havering.gov.uk"
endpoint = f"{URI}/whitespace/GetCollectionByUprnAndDate"
subscription_key = os.getenv("HAVERING_API_SUBSCRIPTION_KEY")
if not subscription_key:
raise RuntimeError("Missing HAVERING_API_SUBSCRIPTION_KEY")
🧰 Tools
🪛 Gitleaks (8.30.0)

[high] 26-26: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@uk_bin_collection/uk_bin_collection/councils/LondonBoroughHavering.py` around
lines 24 - 26, Replace the hardcoded subscription_key with a runtime secret
lookup and fail fast: in the LondonBoroughHavering class/module replace the
literal subscription_key variable with code that reads the key from
environment/secret storage (e.g. using os.environ or your project's secrets
loader) and if the key is missing raise an explicit error/exception immediately
so initialization fails loudly; keep the existing URI and endpoint variables
(URI, endpoint) unchanged and reference the same subscription_key symbol so
callers that use subscription_key continue to work.

@robbrad robbrad merged commit 284fdb1 into master Mar 14, 2026
23 of 25 checks passed
@robbrad robbrad deleted the march-2026-release branch March 14, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment