From 43fa349774beecced532b8151ad83fa8c2df8333 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:03:08 +1200 Subject: [PATCH 01/11] refactor: remove unused `read_csv` method --- src/output.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/output.py b/src/output.py index aaae7ddb..b349b414 100644 --- a/src/output.py +++ b/src/output.py @@ -44,20 +44,6 @@ def get_file_lock(self, path: str) -> threading.Lock: CSVWriter.file_locks[path] = threading.Lock() return CSVWriter.file_locks[path] - def read_csv(self, path: str) -> list[dict[Any, Any]]: - """Read a CSV file as a list of dictionaries. - - Args: - path (str): path to CSV file - - Returns: - list[dict[Any, Any]]: list of dictionaries - """ - with self.get_file_lock(path), open(path, encoding='utf-8-sig') as csvfile: - reader = csv.DictReader(csvfile) - rows = list(reader) - return rows - def add_row(self, row: dict[Any, Any]) -> None: """Add a row to the CSV row buffer. From aa031dcd6b905627070774c6cfbd0893b925447b Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:03:21 +1200 Subject: [PATCH 02/11] refactor: make `get_file_lock` private --- src/output.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/output.py b/src/output.py index b349b414..fee628d9 100644 --- a/src/output.py +++ b/src/output.py @@ -30,7 +30,7 @@ def __init__(self) -> None: """Init variables.""" self.rows: list[dict[Any, Any]] = [] - def get_file_lock(self, path: str) -> threading.Lock: + def _get_file_lock(self, path: str) -> threading.Lock: """Get a lock for a file. Args: @@ -76,7 +76,7 @@ def write_csv_file(self, path: str, overwrite: bool = False) -> bool: keys = self.rows[0].keys() - with self.get_file_lock(path): + with self._get_file_lock(path): file_exists = False if overwrite else os.path.exists(path) file_mode = 'w' if overwrite else 'a' with open(path, file_mode, encoding='utf-8-sig') as csvfile: From b3f638efe3bc3d1eb56910a315792a7fa4cc4f6d Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:32:33 +1200 Subject: [PATCH 03/11] refactor: remove `add_row` in favor of `add_rows` --- src/crawler.py | 16 +++++++++------- src/output.py | 10 +--------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/crawler.py b/src/crawler.py index fff7389e..ff80dd6a 100644 --- a/src/crawler.py +++ b/src/crawler.py @@ -368,13 +368,15 @@ def are_url_headers_acceptable(self, base_url: str, parent_url: str, url_data: s ) # Write bad response codes with CSVWriter csv_writer = src.output.CSVWriter() - csv_writer.add_row( - { - 'base_url': base_url, - 'parent_url': parent_url, - 'url': url_data['final_url'], - 'status_code': url_data['status_code'], - } + csv_writer.add_rows( + [ + { + 'base_url': base_url, + 'parent_url': parent_url, + 'url': url_data['final_url'], + 'status_code': url_data['status_code'], + } + ] ) if self.config.record_unexpected_response_codes: csv_writer.write_csv_file(f'./results/{self.config.audit_name}/unexpected_response_codes.csv') diff --git a/src/output.py b/src/output.py index fee628d9..bf5b40ca 100644 --- a/src/output.py +++ b/src/output.py @@ -44,14 +44,6 @@ def _get_file_lock(self, path: str) -> threading.Lock: CSVWriter.file_locks[path] = threading.Lock() return CSVWriter.file_locks[path] - def add_row(self, row: dict[Any, Any]) -> None: - """Add a row to the CSV row buffer. - - Args: - row (dict[Any, Any]): A dictionary of row contents - """ - self.rows.append(row) - def add_rows(self, rows: list[dict[Any, Any]]) -> None: """Add a list of rows to the CSV row buffer. @@ -209,7 +201,7 @@ def print_progress_bar( 'remaining': f'{time_est}', } - csv_writer.add_row(output_row) + csv_writer.add_rows([output_row]) csv_writer.write_csv_file(f'./results/{config.audit_name}/progress.csv') From 9fe52898dbf43780c74f42eac782b93e9991d425 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:34:22 +1200 Subject: [PATCH 04/11] test: add coverage --- tests/test_output.py | 96 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_output.py diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..8adbd4c5 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,96 @@ +"""Tests the behaviour of the output classes and functions.""" + +import os +import textwrap + +import pytest + +from src.output import CSVWriter + + +@pytest.mark.usefixtures('fs') +class TestCSVWriter: + """Tests writing csv files using the CSVWriter class.""" + + def test_csv_is_not_written_when_no_rows(self) -> None: + """Skips writing a file when no rows have been added.""" + writer = CSVWriter() + + assert not writer.write_csv_file('file.csv') + + assert os.path.exists('file.csv') is not True + + def test_csv_is_written(self) -> None: + """Writes each row to a csv file, with a header.""" + writer = CSVWriter() + + writer.add_rows( + [ + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, + ] + ) + + assert writer.write_csv_file('file.csv') + assert os.path.exists('file.csv') is True + + expected = """ + name,age,location + Bob,20,Wellington + Alice,31,Wellington + Greg,23,Auckland + """ + + with open('file.csv', encoding='utf-8-sig') as f: + assert f.read() == textwrap.dedent(expected).lstrip() + + def test_csv_is_written_with_bom(self) -> None: + """Writes a bom before the header row.""" + writer = CSVWriter() + + writer.add_rows( + [ + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, + ] + ) + + assert writer.write_csv_file('file.csv') + assert os.path.exists('file.csv') is True + + expected = """ + \ufeffname,age,location + Bob,20,Wellington + Alice,31,Wellington + Greg,23,Auckland + """ + + with open('file.csv', encoding='utf-8') as f: + assert f.read() == textwrap.dedent(expected).lstrip() + + def test_column_ordering_is_consistent(self) -> None: + """Orders columns based on the first row.""" + writer = CSVWriter() + + writer.add_rows( + [ + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'age': 31, 'name': 'Alice', 'location': 'Wellington'}, + {'location': 'Auckland', 'age': 23, 'name': 'Greg'}, + ] + ) + + assert writer.write_csv_file('file.csv') + assert os.path.exists('file.csv') is True + + expected = """ + name,age,location + Bob,20,Wellington + Alice,31,Wellington + Greg,23,Auckland + """ + + with open('file.csv', encoding='utf-8-sig') as f: + assert f.read() == textwrap.dedent(expected).lstrip() From 37ca3cc2cd8c13e8fbd9ffc1284eaa98ad9e6c11 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:45:37 +1200 Subject: [PATCH 05/11] refactor: don't bother returning --- src/output.py | 9 ++------- tests/test_output.py | 10 ++++++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/output.py b/src/output.py index bf5b40ca..aaa3bf78 100644 --- a/src/output.py +++ b/src/output.py @@ -53,18 +53,15 @@ def add_rows(self, rows: list[dict[Any, Any]]) -> None: for row in rows: self.rows.append(row) - def write_csv_file(self, path: str, overwrite: bool = False) -> bool: + def write_csv_file(self, path: str, overwrite: bool = False) -> None: """Write data to a CSV file. Args: path (str): path to write data overwrite (bool): overwrite existing file - - Returns: - bool: True if write successful, else False """ if not self.rows: - return False + return keys = self.rows[0].keys() @@ -79,8 +76,6 @@ def write_csv_file(self, path: str, overwrite: bool = False) -> bool: self.rows = [] - return True - def output_init_message(config: Config) -> None: """Print the initial message to stdout and the log.""" diff --git a/tests/test_output.py b/tests/test_output.py index 8adbd4c5..42834a43 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -16,7 +16,7 @@ def test_csv_is_not_written_when_no_rows(self) -> None: """Skips writing a file when no rows have been added.""" writer = CSVWriter() - assert not writer.write_csv_file('file.csv') + writer.write_csv_file('file.csv') assert os.path.exists('file.csv') is not True @@ -32,7 +32,7 @@ def test_csv_is_written(self) -> None: ] ) - assert writer.write_csv_file('file.csv') + writer.write_csv_file('file.csv') assert os.path.exists('file.csv') is True expected = """ @@ -57,7 +57,8 @@ def test_csv_is_written_with_bom(self) -> None: ] ) - assert writer.write_csv_file('file.csv') + writer.write_csv_file('file.csv') + assert os.path.exists('file.csv') is True expected = """ @@ -82,7 +83,8 @@ def test_column_ordering_is_consistent(self) -> None: ] ) - assert writer.write_csv_file('file.csv') + writer.write_csv_file('file.csv') + assert os.path.exists('file.csv') is True expected = """ From 0146529daf2a8849a162e659153cbc4842f5b285 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:47:58 +1200 Subject: [PATCH 06/11] refactor: remove overwrite param --- src/output.py | 10 ++++------ tests/test_output.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/output.py b/src/output.py index aaa3bf78..23fa8863 100644 --- a/src/output.py +++ b/src/output.py @@ -53,12 +53,11 @@ def add_rows(self, rows: list[dict[Any, Any]]) -> None: for row in rows: self.rows.append(row) - def write_csv_file(self, path: str, overwrite: bool = False) -> None: + def write_csv_file(self, path: str) -> None: """Write data to a CSV file. Args: path (str): path to write data - overwrite (bool): overwrite existing file """ if not self.rows: return @@ -66,11 +65,10 @@ def write_csv_file(self, path: str, overwrite: bool = False) -> None: keys = self.rows[0].keys() with self._get_file_lock(path): - file_exists = False if overwrite else os.path.exists(path) - file_mode = 'w' if overwrite else 'a' - with open(path, file_mode, encoding='utf-8-sig') as csvfile: + file_already_exists = os.path.exists(path) + with open(path, 'a', encoding='utf-8-sig') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=keys) - if not file_exists: + if not file_already_exists: writer.writeheader() writer.writerows(self.rows) diff --git a/tests/test_output.py b/tests/test_output.py index 42834a43..6081a619 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -71,6 +71,39 @@ def test_csv_is_written_with_bom(self) -> None: with open('file.csv', encoding='utf-8') as f: assert f.read() == textwrap.dedent(expected).lstrip() + def test_multiple_writes_to_same_file(self) -> None: + """Writes new rows to a csv file, without duplicating the header.""" + writer = CSVWriter() + + writer.add_rows( + [ + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + ] + ) + + writer.write_csv_file('file.csv') + + assert os.path.exists('file.csv') is True + + writer.add_rows( + [ + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, + ] + ) + + writer.write_csv_file('file.csv') + + expected = """ + name,age,location + Bob,20,Wellington + Alice,31,Wellington + Greg,23,Auckland + """ + + with open('file.csv', encoding='utf-8-sig') as f: + assert f.read() == textwrap.dedent(expected).lstrip() + def test_column_ordering_is_consistent(self) -> None: """Orders columns based on the first row.""" writer = CSVWriter() From 187b930083919ed9594b7c1c4e8e8716f334b2a4 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:58:00 +1200 Subject: [PATCH 07/11] refactor: take rows as a variadic param --- src/audit_manager.py | 20 +++++++++----------- src/crawler.py | 42 ++++++++++++++++++------------------------ src/output.py | 4 ++-- tests/test_output.py | 34 ++++++++++++---------------------- 4 files changed, 41 insertions(+), 59 deletions(-) diff --git a/src/audit_manager.py b/src/audit_manager.py index b90dc993..fd5d8ba1 100644 --- a/src/audit_manager.py +++ b/src/audit_manager.py @@ -138,15 +138,13 @@ def test_for_anti_bot(self) -> str: # Write to anti-bot.csv csv_writer = CSVWriter() csv_writer.add_rows( - [ - { - 'organisation': org['organisation'], - 'domain': netloc, - 'url': url, - 'anti_bot_check': status, - 'viewport_size': self.browser.viewport_size, - } - ] + { + 'organisation': org['organisation'], + 'domain': netloc, + 'url': url, + 'anti_bot_check': status, + 'viewport_size': self.browser.viewport_size, + } ) csv_writer.write_csv_file(f'./results/{self.config.audit_name}/anti_bot.csv') self.discarded_urls[url] = status @@ -287,7 +285,7 @@ def run_audits(self) -> bool: # noqa: PLR0915 test_instance = audit['audit_class'](config=self.config, browser=self.browser, **audit['kwargs']) try: - audit_result = test_instance.run() + audit_result: list[dict[str, Any]] | bool = test_instance.run() except selenium.common.exceptions.WebDriverException: logger.exception( 'Due to WebDriverException, test %s skipped on viewport %s for website %s', @@ -359,7 +357,7 @@ def run_audits(self) -> bool: # noqa: PLR0915 # Write results csv_writer = CSVWriter() - csv_writer.add_rows(audit_result) + csv_writer.add_rows(*audit_result) csv_writer.write_csv_file(f'./results/{self.config.audit_name}/{audit_name}.csv') # At least one audit successfully produced results diff --git a/src/crawler.py b/src/crawler.py index ff80dd6a..33160672 100644 --- a/src/crawler.py +++ b/src/crawler.py @@ -369,14 +369,12 @@ def are_url_headers_acceptable(self, base_url: str, parent_url: str, url_data: s # Write bad response codes with CSVWriter csv_writer = src.output.CSVWriter() csv_writer.add_rows( - [ - { - 'base_url': base_url, - 'parent_url': parent_url, - 'url': url_data['final_url'], - 'status_code': url_data['status_code'], - } - ] + { + 'base_url': base_url, + 'parent_url': parent_url, + 'url': url_data['final_url'], + 'status_code': url_data['status_code'], + } ) if self.config.record_unexpected_response_codes: csv_writer.write_csv_file(f'./results/{self.config.audit_name}/unexpected_response_codes.csv') @@ -575,14 +573,12 @@ def crawl(self, site_data: SiteData, base_url: str) -> None: # noqa: PLR0912, P # Write to audit_log.csv csv_writer = CSVWriter() csv_writer.add_rows( - [ - { - 'organisation': site_data['organisation'], - 'base_url': site_data['url'], - 'url': url, - 'sector': site_data['sector'], - } - ] + { + 'organisation': site_data['organisation'], + 'base_url': site_data['url'], + 'url': url, + 'sector': site_data['sector'], + } ) csv_writer.write_csv_file(f'./results/{self.config.audit_name}/audit_log.csv') @@ -652,14 +648,12 @@ def record_pages_scanned(self, site_data: SiteData, pages_scanned: int) -> None: with self.config.lock: csv_writer = src.output.CSVWriter() csv_writer.add_rows( - [ - { - 'organisation': site_data['organisation'], - 'base_url': site_data['url'], - 'number_of_pages': pages_scanned, - 'sector': site_data['sector'], - } - ] + { + 'organisation': site_data['organisation'], + 'base_url': site_data['url'], + 'number_of_pages': pages_scanned, + 'sector': site_data['sector'], + } ) csv_writer.write_csv_file(f'./results/{self.config.audit_name}/pages_scanned.csv') diff --git a/src/output.py b/src/output.py index 23fa8863..1b1ab728 100644 --- a/src/output.py +++ b/src/output.py @@ -44,7 +44,7 @@ def _get_file_lock(self, path: str) -> threading.Lock: CSVWriter.file_locks[path] = threading.Lock() return CSVWriter.file_locks[path] - def add_rows(self, rows: list[dict[Any, Any]]) -> None: + def add_rows(self, *rows: dict[Any, Any]) -> None: """Add a list of rows to the CSV row buffer. Args: @@ -194,7 +194,7 @@ def print_progress_bar( 'remaining': f'{time_est}', } - csv_writer.add_rows([output_row]) + csv_writer.add_rows(output_row) csv_writer.write_csv_file(f'./results/{config.audit_name}/progress.csv') diff --git a/tests/test_output.py b/tests/test_output.py index 6081a619..de2f9e56 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -25,11 +25,9 @@ def test_csv_is_written(self) -> None: writer = CSVWriter() writer.add_rows( - [ - {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, - {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, - {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, - ] + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, ) writer.write_csv_file('file.csv') @@ -50,11 +48,9 @@ def test_csv_is_written_with_bom(self) -> None: writer = CSVWriter() writer.add_rows( - [ - {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, - {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, - {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, - ] + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, ) writer.write_csv_file('file.csv') @@ -76,9 +72,7 @@ def test_multiple_writes_to_same_file(self) -> None: writer = CSVWriter() writer.add_rows( - [ - {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, - ] + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, ) writer.write_csv_file('file.csv') @@ -86,10 +80,8 @@ def test_multiple_writes_to_same_file(self) -> None: assert os.path.exists('file.csv') is True writer.add_rows( - [ - {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, - {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, - ] + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, ) writer.write_csv_file('file.csv') @@ -109,11 +101,9 @@ def test_column_ordering_is_consistent(self) -> None: writer = CSVWriter() writer.add_rows( - [ - {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, - {'age': 31, 'name': 'Alice', 'location': 'Wellington'}, - {'location': 'Auckland', 'age': 23, 'name': 'Greg'}, - ] + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'age': 31, 'name': 'Alice', 'location': 'Wellington'}, + {'location': 'Auckland', 'age': 23, 'name': 'Greg'}, ) writer.write_csv_file('file.csv') From 462a1118c39842f5fe597f1907d558e3c1bd3540 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:06:16 +1200 Subject: [PATCH 08/11] refactor: merge `add_rows` and `write_csv_file` --- src/audit_manager.py | 10 ++++----- src/crawler.py | 33 ++++++++++++++-------------- src/output.py | 52 +++++++++++++++----------------------------- tests/test_output.py | 28 ++++++++++-------------- 4 files changed, 51 insertions(+), 72 deletions(-) diff --git a/src/audit_manager.py b/src/audit_manager.py index fd5d8ba1..2210416b 100644 --- a/src/audit_manager.py +++ b/src/audit_manager.py @@ -137,16 +137,17 @@ def test_for_anti_bot(self) -> str: # Write to anti-bot.csv csv_writer = CSVWriter() - csv_writer.add_rows( + csv_writer.append_rows( + f'./results/{self.config.audit_name}/anti_bot.csv', { 'organisation': org['organisation'], 'domain': netloc, 'url': url, 'anti_bot_check': status, 'viewport_size': self.browser.viewport_size, - } + }, ) - csv_writer.write_csv_file(f'./results/{self.config.audit_name}/anti_bot.csv') + self.discarded_urls[url] = status return status @@ -357,8 +358,7 @@ def run_audits(self) -> bool: # noqa: PLR0915 # Write results csv_writer = CSVWriter() - csv_writer.add_rows(*audit_result) - csv_writer.write_csv_file(f'./results/{self.config.audit_name}/{audit_name}.csv') + csv_writer.append_rows(f'./results/{self.config.audit_name}/{audit_name}.csv', *audit_result) # At least one audit successfully produced results any_audit_succeeded = True diff --git a/src/crawler.py b/src/crawler.py index 33160672..6a00c286 100644 --- a/src/crawler.py +++ b/src/crawler.py @@ -366,18 +366,17 @@ def are_url_headers_acceptable(self, base_url: str, parent_url: str, url_data: s url_data['final_url'], url_data['status_code'], ) - # Write bad response codes with CSVWriter - csv_writer = src.output.CSVWriter() - csv_writer.add_rows( - { - 'base_url': base_url, - 'parent_url': parent_url, - 'url': url_data['final_url'], - 'status_code': url_data['status_code'], - } - ) if self.config.record_unexpected_response_codes: - csv_writer.write_csv_file(f'./results/{self.config.audit_name}/unexpected_response_codes.csv') + csv_writer = src.output.CSVWriter() + csv_writer.append_rows( + f'./results/{self.config.audit_name}/unexpected_response_codes.csv', + { + 'base_url': base_url, + 'parent_url': parent_url, + 'url': url_data['final_url'], + 'status_code': url_data['status_code'], + }, + ) return False return src.filters.url_filter_by_header_content_type(url_data['final_url'], url_data['headers']) @@ -572,15 +571,15 @@ def crawl(self, site_data: SiteData, base_url: str) -> None: # noqa: PLR0912, P # Write to audit_log.csv csv_writer = CSVWriter() - csv_writer.add_rows( + csv_writer.append_rows( + f'./results/{self.config.audit_name}/audit_log.csv', { 'organisation': site_data['organisation'], 'base_url': site_data['url'], 'url': url, 'sector': site_data['sector'], - } + }, ) - csv_writer.write_csv_file(f'./results/{self.config.audit_name}/audit_log.csv') self.register_audit_plugins(audit_manager, url, site_data) test_success = audit_manager.run_audits() @@ -647,15 +646,15 @@ def record_pages_scanned(self, site_data: SiteData, pages_scanned: int) -> None: """Record the number of pages that were scanned for the site.""" with self.config.lock: csv_writer = src.output.CSVWriter() - csv_writer.add_rows( + csv_writer.append_rows( + f'./results/{self.config.audit_name}/pages_scanned.csv', { 'organisation': site_data['organisation'], 'base_url': site_data['url'], 'number_of_pages': pages_scanned, 'sector': site_data['sector'], - } + }, ) - csv_writer.write_csv_file(f'./results/{self.config.audit_name}/pages_scanned.csv') class RandomQueue[T]: diff --git a/src/output.py b/src/output.py index 1b1ab728..664f4d9f 100644 --- a/src/output.py +++ b/src/output.py @@ -26,10 +26,6 @@ class CSVWriter: # A lock to prevent multiple threads writing to file_locks dict lock_for_file_locks = threading.Lock() - def __init__(self) -> None: - """Init variables.""" - self.rows: list[dict[Any, Any]] = [] - def _get_file_lock(self, path: str) -> threading.Lock: """Get a lock for a file. @@ -44,25 +40,17 @@ def _get_file_lock(self, path: str) -> threading.Lock: CSVWriter.file_locks[path] = threading.Lock() return CSVWriter.file_locks[path] - def add_rows(self, *rows: dict[Any, Any]) -> None: - """Add a list of rows to the CSV row buffer. + def append_rows(self, path: str, *rows: dict[Any, Any]) -> None: + """Add a list of rows to a CSV file. Args: + path (str): path to file rows (list[dict[Any, Any]]): list of rows of data """ - for row in rows: - self.rows.append(row) - - def write_csv_file(self, path: str) -> None: - """Write data to a CSV file. - - Args: - path (str): path to write data - """ - if not self.rows: + if not rows: return - keys = self.rows[0].keys() + keys = rows[0].keys() with self._get_file_lock(path): file_already_exists = os.path.exists(path) @@ -70,9 +58,7 @@ def write_csv_file(self, path: str) -> None: writer = csv.DictWriter(csvfile, fieldnames=keys) if not file_already_exists: writer.writeheader() - writer.writerows(self.rows) - - self.rows = [] + writer.writerows(rows) def output_init_message(config: Config) -> None: @@ -183,20 +169,18 @@ def print_progress_bar( # Write progress data to CSV file csv_writer = CSVWriter() - - output_row = { - 'time': time.time(), - 'iteration': iteration, - 'total': total, - 'speed': f'{speed:.2f}', - 'percent': percent, - 'elapsed': f'{elapsed}', - 'remaining': f'{time_est}', - } - - csv_writer.add_rows(output_row) - - csv_writer.write_csv_file(f'./results/{config.audit_name}/progress.csv') + csv_writer.append_rows( + f'./results/{config.audit_name}/progress.csv', + { + 'time': time.time(), + 'iteration': iteration, + 'total': total, + 'speed': f'{speed:.2f}', + 'percent': percent, + 'elapsed': f'{elapsed}', + 'remaining': f'{time_est}', + }, + ) # Print New Line on Complete if iteration == total: diff --git a/tests/test_output.py b/tests/test_output.py index de2f9e56..34fb850b 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -16,7 +16,7 @@ def test_csv_is_not_written_when_no_rows(self) -> None: """Skips writing a file when no rows have been added.""" writer = CSVWriter() - writer.write_csv_file('file.csv') + writer.append_rows('file.csv') assert os.path.exists('file.csv') is not True @@ -24,13 +24,13 @@ def test_csv_is_written(self) -> None: """Writes each row to a csv file, with a header.""" writer = CSVWriter() - writer.add_rows( + writer.append_rows( + 'file.csv', {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, ) - writer.write_csv_file('file.csv') assert os.path.exists('file.csv') is True expected = """ @@ -47,14 +47,13 @@ def test_csv_is_written_with_bom(self) -> None: """Writes a bom before the header row.""" writer = CSVWriter() - writer.add_rows( + writer.append_rows( + 'file.csv', {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, ) - writer.write_csv_file('file.csv') - assert os.path.exists('file.csv') is True expected = """ @@ -67,25 +66,23 @@ def test_csv_is_written_with_bom(self) -> None: with open('file.csv', encoding='utf-8') as f: assert f.read() == textwrap.dedent(expected).lstrip() - def test_multiple_writes_to_same_file(self) -> None: + def test_appends_to_existing_file(self) -> None: """Writes new rows to a csv file, without duplicating the header.""" writer = CSVWriter() - writer.add_rows( + writer.append_rows( + 'file.csv', {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, ) - writer.write_csv_file('file.csv') - assert os.path.exists('file.csv') is True - writer.add_rows( + writer.append_rows( + 'file.csv', {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, ) - writer.write_csv_file('file.csv') - expected = """ name,age,location Bob,20,Wellington @@ -100,14 +97,13 @@ def test_column_ordering_is_consistent(self) -> None: """Orders columns based on the first row.""" writer = CSVWriter() - writer.add_rows( + writer.append_rows( + 'file.csv', {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, {'age': 31, 'name': 'Alice', 'location': 'Wellington'}, {'location': 'Auckland', 'age': 23, 'name': 'Greg'}, ) - writer.write_csv_file('file.csv') - assert os.path.exists('file.csv') is True expected = """ From 19629b3e0b9fa2fa6e08ece4be1e8ed990318f97 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:12:09 +1200 Subject: [PATCH 09/11] chore: remove unneeded disable --- src/output.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/output.py b/src/output.py index 664f4d9f..c67d9c89 100644 --- a/src/output.py +++ b/src/output.py @@ -11,8 +11,6 @@ from config import Config -# pylint: disable=too-many-locals - logger = logging.getLogger('cwac') From 07f9adf35bcca1ce78f9f851944a2f836b7c4754 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:16:58 +1200 Subject: [PATCH 10/11] chore: update doc comment --- src/output.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/output.py b/src/output.py index c67d9c89..cf2ffb07 100644 --- a/src/output.py +++ b/src/output.py @@ -39,11 +39,11 @@ def _get_file_lock(self, path: str) -> threading.Lock: return CSVWriter.file_locks[path] def append_rows(self, path: str, *rows: dict[Any, Any]) -> None: - """Add a list of rows to a CSV file. + """Append one or more rows to a CSV file. Args: path (str): path to file - rows (list[dict[Any, Any]]): list of rows of data + *rows (dict[str, Any]): one or more rows of data """ if not rows: return From 4c8453aa5b92b5d8a0dffc4ef68d1d7ba434d2af Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:23:05 +1200 Subject: [PATCH 11/11] test: add more coverage --- tests/test_output.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_output.py b/tests/test_output.py index 34fb850b..11adabb8 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -93,6 +93,44 @@ def test_appends_to_existing_file(self) -> None: with open('file.csv', encoding='utf-8-sig') as f: assert f.read() == textwrap.dedent(expected).lstrip() + def test_ignores_missing_columns(self) -> None: + """Ignores columns missing in subsequent rows.""" + writer = CSVWriter() + + writer.append_rows( + 'file.csv', + {'name': 'Bob', 'age': 20, 'location': 'Wellington'}, + {'name': 'Alice', 'age': 31}, + {'age': 23, 'location': 'Auckland'}, + ) + + assert os.path.exists('file.csv') is True + + expected = """ + name,age,location + Bob,20,Wellington + Alice,31, + ,23,Auckland + """ + + with open('file.csv', encoding='utf-8-sig') as f: + assert f.read() == textwrap.dedent(expected).lstrip() + + def test_raises_extra_columns(self) -> None: + """Errors when rows have extra columns.""" + writer = CSVWriter() + + with pytest.raises(ValueError): + writer.append_rows( + 'file.csv', + {'name': 'Bob', 'age': 20}, + {'name': 'Alice', 'age': 31, 'location': 'Wellington'}, + {'name': 'Greg', 'age': 23, 'location': 'Auckland'}, + ) + + # the file will still end up being created due to the open mode + assert os.path.exists('file.csv') + def test_column_ordering_is_consistent(self) -> None: """Orders columns based on the first row.""" writer = CSVWriter()