From c400617370b12d0fca52d3e4f7c593ebba4b67bd Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Mon, 9 Mar 2026 13:21:27 +1000 Subject: [PATCH] Respect valid pyproject readme paths --- github_linter/tests/pyproject.py | 46 ++++++- tests/test_pyproject.py | 208 +++++++++++++++++++------------ 2 files changed, 169 insertions(+), 85 deletions(-) diff --git a/github_linter/tests/pyproject.py b/github_linter/tests/pyproject.py index 98e6873..a500d98 100644 --- a/github_linter/tests/pyproject.py +++ b/github_linter/tests/pyproject.py @@ -84,15 +84,21 @@ def validate_readme_configured( repo.error(CATEGORY, "No 'readme' field in [project] section of config") return False - expected_readme = repo.config[CATEGORY]["readme"] - project_readme = project_object["readme"] - if project_readme != expected_readme: + if not isinstance(project_readme, str): + repo.error( + CATEGORY, + f"Readme invalid - expected a string path, found {type(project_readme).__name__}", + ) + return False + + if repo.cached_get_file(project_readme) is None: repo.error( CATEGORY, - f"Readme invalid - should be {expected_readme}, is {project_readme}", + f"Readme invalid - file not found: {project_readme}", ) return False + return True @@ -167,6 +173,7 @@ def check_pyproject_toml( validate_pyproject_authors(repo, project) # TODO: make this its own check validate_project_name(repo, project) + validate_readme_configured(repo, project) if "urls" in project: for url in project["urls"]: @@ -174,6 +181,37 @@ def check_pyproject_toml( return None +def fix_pyproject_readme(repo: RepoLinter) -> None: + """ensures the project readme points to a file that exists""" + pyproject = repo.load_pyproject() + pyproject_file = repo.cached_get_file("pyproject.toml") + + if pyproject is None or pyproject_file is None: + logger.info("No pyproject file found in repo {}", repo.repository.full_name) + return + + project = pyproject.get("project") + if not isinstance(project, dict): + logger.info("No project section found in pyproject.toml for {}", repo.repository.full_name) + return + + project_readme = project.get("readme") + if isinstance(project_readme, str) and repo.cached_get_file(project_readme) is not None: + logger.debug("Readme path already valid in pyproject.toml: {}", project_readme) + return + + expected_readme = repo.config[CATEGORY]["readme"] + project["readme"] = expected_readme + result = repo.create_or_update_file( + "pyproject.toml", + tomli_w.dumps(pyproject), + pyproject_file, + "Updated project.readme in pyproject.toml", + ) + if result: + repo.fix(CATEGORY, f"Updated pyproject.toml readme setting - commit url {result}") + + # TODO: moving away from flit, don't need this # def check_sdist_exclude_list( # repo: RepoLinter, diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 681cec2..1aef773 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -1,81 +1,127 @@ -"""testing pyproject""" - -# from io import BytesIO - -# from github.ContentFile import ContentFile -# from github_linter.tests.pyproject import validate_project_name, validate_readme_configured - - -# class TestRepoFoo: -# """ just for testing """ -# name = "foobar" - -# def get_contents(filename: str): -# """ kinda like a file, but not really """ -# if filename == "README.md": -# readme = ContentFile("","","","",) -# readme._content.value = open("README.md", encoding="utf8").read() -# return readme -# return BytesIO() - -# class TestGithub: -# """ test instance """ -# config = { -# "pyproject.toml" : { -# "readme" : "README.md" -# } -# } - -# def test_validate_project_name_fails_when_bad(): -# """ if the name doesn't match, then we should yell """ - -# testproject = { -# "project" : { -# "name" : "zotbar" -# } -# } -# assert not validate_project_name(None, TestRepoFoo, testproject, {}, {}) - - -# def test_validate_project_name_good(): -# """ if the name matches we're good """ -# testproject = { -# "name" : "foobar" -# } -# assert validate_project_name(None, TestRepoFoo, testproject, {}, {}) - -# def test_validate_project_name_fails_when_missing(): -# """ if the name is missing we yell """ - -# testproject = { -# # "name" : "foobar" -# } -# assert not validate_project_name(None, TestRepoFoo, testproject, {}, {}) - -# def test_validate_readme_configured_invalid(): -# """ checks the readme is set and is invalid """ -# testproject = { -# "name" : "zotbar", -# "readme" : "foobar" -# } - -# errors_object = {} -# warnings_object = {} -# result = validate_readme_configured(TestGithub, TestRepoFoo, testproject, errors_object, warnings_object) -# assert errors_object -# assert not warnings_object -# assert not result - -# def test_validate_readme_configured(): -# """ checks the readme is set and is invalid """ - -# testproject = { -# "name" : "zotbar", -# "readme" : "README.md" -# } -# errors_object = {} -# warnings_object = {} -# result = validate_readme_configured(TestGithub, testproject, errors_object, warnings_object) -# assert not errors_object -# assert not warnings_object -# assert result +"""Tests for pyproject module.""" + +from unittest.mock import Mock + +from github_linter.repolinter import RepoLinter +from github_linter.tests.pyproject import check_pyproject_toml, fix_pyproject_readme + + +def create_mock_pyproject_file() -> Mock: + """Create a mock pyproject.toml file object.""" + mock_file = Mock() + mock_file.decoded_content = b"[project]\nname = 'test1'\n" + mock_file.sha = "deadbeef" + return mock_file + + +def create_mock_repo(pyproject: dict, existing_files: set[str] | None = None) -> Mock: + """Create a mocked RepoLinter for pyproject tests.""" + mock_repo = Mock(spec=RepoLinter) + mock_repo.config = {"pyproject": {"readme": "README.md"}} + mock_repo.repository = Mock() + mock_repo.repository.name = "test1" + mock_repo.repository.full_name = "testuser/test1" + pyproject_file = create_mock_pyproject_file() + files = existing_files or set() + + def cached_get_file(path: str) -> Mock | None: + if path == "pyproject.toml": + return pyproject_file + if path in files: + mock_file = Mock() + mock_file.decoded_content = b"file" + return mock_file + return None + + mock_repo.load_pyproject.return_value = pyproject + mock_repo.cached_get_file.side_effect = cached_get_file + return mock_repo + + +def test_check_pyproject_toml_accepts_default_readme_path() -> None: + """No error when the configured readme file exists.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "authors": [], "readme": "README.md"}}, {"README.md"}) + + check_pyproject_toml(mock_repo) + + mock_repo.error.assert_not_called() + + +def test_check_pyproject_toml_accepts_alternate_existing_readme_path() -> None: + """No error when readme points at another file that exists.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "authors": [], "readme": "docs/README.md"}}, {"docs/README.md"}) + + check_pyproject_toml(mock_repo) + + mock_repo.error.assert_not_called() + + +def test_check_pyproject_toml_errors_when_readme_missing() -> None: + """Missing readme should be reported.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "authors": []}}) + + check_pyproject_toml(mock_repo) + + mock_repo.error.assert_any_call("pyproject", "No 'readme' field in [project] section of config") + + +def test_check_pyproject_toml_errors_when_readme_file_missing() -> None: + """Missing readme file target should be reported.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "authors": [], "readme": "docs/README.md"}}) + + check_pyproject_toml(mock_repo) + + mock_repo.error.assert_any_call("pyproject", "Readme invalid - file not found: docs/README.md") + + +def test_check_pyproject_toml_errors_when_readme_not_string() -> None: + """Non-string readme metadata should be rejected.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "authors": [], "readme": {"file": "README.md"}}}) + + check_pyproject_toml(mock_repo) + + mock_repo.error.assert_any_call("pyproject", "Readme invalid - expected a string path, found dict") + + +def test_fix_pyproject_readme_sets_default_when_missing() -> None: + """Fix should add the configured readme when absent.""" + mock_repo = create_mock_repo({"project": {"name": "test1"}}) + mock_repo.create_or_update_file.return_value = "https://example.com/commit/1" + + fix_pyproject_readme(mock_repo) + + updated_contents = mock_repo.create_or_update_file.call_args.args[1] + assert 'readme = "README.md"' in updated_contents + mock_repo.fix.assert_called_once_with("pyproject", "Updated pyproject.toml readme setting - commit url https://example.com/commit/1") + + +def test_fix_pyproject_readme_sets_default_when_invalid() -> None: + """Fix should replace an invalid readme path.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "readme": "missing.md"}}) + mock_repo.create_or_update_file.return_value = "https://example.com/commit/2" + + fix_pyproject_readme(mock_repo) + + updated_contents = mock_repo.create_or_update_file.call_args.args[1] + assert 'readme = "README.md"' in updated_contents + mock_repo.fix.assert_called_once_with("pyproject", "Updated pyproject.toml readme setting - commit url https://example.com/commit/2") + + +def test_fix_pyproject_readme_does_not_write_when_path_is_valid() -> None: + """Valid existing readme paths should be left alone.""" + mock_repo = create_mock_repo({"project": {"name": "test1", "readme": "docs/README.md"}}, {"docs/README.md"}) + + fix_pyproject_readme(mock_repo) + + mock_repo.create_or_update_file.assert_not_called() + mock_repo.fix.assert_not_called() + + +def test_fix_pyproject_readme_does_not_create_project_section() -> None: + """Fix should not invent a project section.""" + mock_repo = create_mock_repo({"tool": {"hatch": {}}}) + + fix_pyproject_readme(mock_repo) + + mock_repo.create_or_update_file.assert_not_called() + mock_repo.fix.assert_not_called()