From 34c3e30baa7fe6270261001bb2d34268351af965 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Sat, 29 Aug 2026 22:50:02 +0530 Subject: [PATCH 1/2] test: annotate the suite so mypy passes in strict mode `[tool.mypy]` has been `strict = true` since the package was set up, but nothing ran it, so 54 errors accumulated across five test files. All but five were missing annotations on test functions and their fixture parameters. The five that were not: - `spec_from_file_location` returns `ModuleSpec | None`, narrowed before use rather than after. - `update_changelog_text` comes off a dynamically loaded module and so is `Any`; bound to a declared `str` on the way out. - `FakeOpener.requests` held `object`, which made `.headers` unreachable on the `Request` the fetch actually appends. - `vector_distance_sql` is passed an invalid metric on purpose, so that call carries a scoped ignore. Package source was already clean. --- tests/conftest.py | 7 +- tests/test_package.py | 8 +-- tests/test_search.py | 6 +- tests/test_tools.py | 128 +++++++++++++++++++++------------ tests/test_update_changelog.py | 13 ++-- 5 files changed, 104 insertions(+), 58 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0676ba3..9466ca5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch +from urllib.request import Request import pyarrow as pa import pyarrow.parquet as pq @@ -33,9 +34,9 @@ class FakeOpener: def __init__(self, payload: bytes, headers: dict[str, str]) -> None: self._payload = payload self._headers = headers - self.requests: list[object] = [] + self.requests: list[Request] = [] - def open(self, request: object, timeout: float | None = None) -> FakeResponse: + def open(self, request: Request, timeout: float | None = None) -> FakeResponse: self.requests.append(request) return FakeResponse(self._payload, self._headers) @@ -196,7 +197,7 @@ def search_result() -> QueryResult: @pytest.fixture -def mock_client(sample_result: QueryResult): +def mock_client(sample_result: QueryResult) -> MagicMock: client = MagicMock() client.workspace_id = "ws_test" client.execute_sql = MagicMock(return_value=sample_result) diff --git a/tests/test_package.py b/tests/test_package.py index 4105335..e54092d 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -18,21 +18,21 @@ _LANGCHAIN_IMPORT = re.compile(r"(?m)^\s*(?:from|import)\s+langchain(?=[.\s,]|$)") -def test_version_is_pep440_core(): +def test_version_is_pep440_core() -> None: assert re.fullmatch(r"\d+\.\d+\.\d+(\+.*)?", hl.__version__) -def test_version_matches_distribution_metadata(): +def test_version_matches_distribution_metadata() -> None: assert dist_version("hotdata-langchain") == hl.__version__ @pytest.mark.parametrize("name", hl.__all__) -def test_public_export_is_importable(name: str): +def test_public_export_is_importable(name: str) -> None: assert hasattr(hl, name), f"missing export: {name}" assert getattr(hl, name) is not None -def test_source_uses_hotdata_framework_root_imports(): +def test_source_uses_hotdata_framework_root_imports() -> None: violations: list[str] = [] for path in SOURCE_ROOT.rglob("*.py"): if _RUNTIME_SUBMODULE.search(path.read_text(encoding="utf-8")): diff --git a/tests/test_search.py b/tests/test_search.py index dbfde7e..c16c9b1 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -670,7 +670,11 @@ def test_vector_distance_sql_never_projects_the_vector_column() -> None: def test_vector_distance_sql_rejects_a_metric_it_has_no_function_for() -> None: with pytest.raises(ValueError, match="metric must be one of"): vector_distance_sql( - table=SEMANTIC_TABLE, column="embedding", vector=[0.1], columns=["id"], metric="jaccard" + table=SEMANTIC_TABLE, + column="embedding", + vector=[0.1], + columns=["id"], + metric="jaccard", # type: ignore[arg-type] ) diff --git a/tests/test_tools.py b/tests/test_tools.py index e32f2d1..8fdb29d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -3,13 +3,14 @@ import json import socket import tempfile +from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, NoReturn from unittest.mock import MagicMock from urllib.request import Request import pytest -from hotdata_framework import LoadManagedTableResult, ManagedDatabase +from hotdata_framework import LoadManagedTableResult, ManagedDatabase, QueryResult from hotdata_langchain.databases import ( _ValidatingRedirectHandler, @@ -34,24 +35,26 @@ from tests.conftest import FakeOpener -def test_result_rows_for_llm(sample_result): +def test_result_rows_for_llm(sample_result: QueryResult) -> None: rows = result_rows_for_llm(sample_result, max_rows=1) assert rows == [{"n": 1}] -def test_execute_sql_json(mock_client, sample_result): +def test_execute_sql_json(mock_client: MagicMock, sample_result: QueryResult) -> None: payload = json.loads(execute_sql_json(mock_client, "select 1")) assert payload["metadata"]["row_count"] == 2 assert payload["rows"] == [{"n": 1}, {"n": 2}] mock_client.execute_sql.assert_called_once_with("select 1", database=None) -def test_execute_sql_json_with_database(mock_client, sample_result, managed_db): +def test_execute_sql_json_with_database( + mock_client: MagicMock, sample_result: QueryResult, managed_db: ManagedDatabase +) -> None: execute_sql_json(mock_client, "select 1", database_id=managed_db) mock_client.execute_sql.assert_called_once_with("select 1", database=managed_db) -def test_list_managed_databases_json(mock_client): +def test_list_managed_databases_json(mock_client: MagicMock) -> None: mock_client.list_managed_databases.return_value = [ ManagedDatabase(id="c1", description="sales", default_connection_id="conn_c1"), ] @@ -59,7 +62,7 @@ def test_list_managed_databases_json(mock_client): assert payload[0] == {"id": "c1", "name": "sales"} -def test_create_managed_database_delegates(mock_client): +def test_create_managed_database_delegates(mock_client: MagicMock) -> None: mock_client.create_managed_database.return_value = ManagedDatabase( id="c1", description="sales", @@ -88,7 +91,9 @@ def test_the_create_tool_reports_the_database_under_name(mock_client: MagicMock) assert payload == {"id": "c1", "name": "sales"} -def test_load_managed_table_delegates(mock_client, managed_db, parquet_file): +def test_load_managed_table_delegates( + mock_client: MagicMock, managed_db: ManagedDatabase, parquet_file: Path +) -> None: """The load addresses the resolved record, so no name can select the target.""" mock_client.load_managed_table.return_value = LoadManagedTableResult( connection_id="c1", @@ -112,7 +117,9 @@ def test_load_managed_table_delegates(mock_client, managed_db, parquet_file): assert loaded.row_count == 3 -def test_load_managed_table_rejects_a_path_that_is_not_there(mock_client, managed_db): +def test_load_managed_table_rejects_a_path_that_is_not_there( + mock_client: MagicMock, managed_db: ManagedDatabase +) -> None: """A raw FileNotFoundError says nothing about what the tool would have accepted.""" with pytest.raises(FileNotFoundError, match="https:// URL"): load_managed_table( @@ -124,7 +131,12 @@ def test_load_managed_table_rejects_a_path_that_is_not_there(mock_client, manage mock_client.load_managed_table.assert_not_called() -def test_load_managed_table_accepts_a_url(mock_client, managed_db, parquet_file, serve): +def test_load_managed_table_accepts_a_url( + mock_client: MagicMock, + managed_db: ManagedDatabase, + parquet_file: Path, + serve: Callable[..., FakeOpener], +) -> None: """The only ingest route open to a deployed agent, which has no filesystem of its own.""" mock_client.load_managed_table.return_value = LoadManagedTableResult( connection_id="c1", @@ -146,8 +158,11 @@ def test_load_managed_table_accepts_a_url(mock_client, managed_db, parquet_file, def test_the_downloaded_copy_is_removed_after_the_load( - mock_client, managed_db, parquet_file, serve -): + mock_client: MagicMock, + managed_db: ManagedDatabase, + parquet_file: Path, + serve: Callable[..., FakeOpener], +) -> None: """A tool an agent calls in a loop must not leave a file behind on every call.""" serve(parquet_file.read_bytes()) load_managed_table( @@ -160,8 +175,11 @@ def test_the_downloaded_copy_is_removed_after_the_load( def test_the_downloaded_copy_is_removed_when_the_load_fails( - mock_client, managed_db, parquet_file, serve -): + mock_client: MagicMock, + managed_db: ManagedDatabase, + parquet_file: Path, + serve: Callable[..., FakeOpener], +) -> None: mock_client.load_managed_table.side_effect = RuntimeError("Bad Request") serve(parquet_file.read_bytes()) with pytest.raises(RuntimeError): @@ -174,18 +192,22 @@ def test_the_downloaded_copy_is_removed_when_the_load_fails( assert not Path(mock_client.load_managed_table.call_args.kwargs["file"]).exists() -def test_a_url_that_returns_a_login_page_is_rejected_before_upload(serve): +def test_a_url_that_returns_a_login_page_is_rejected_before_upload( + serve: Callable[..., FakeOpener], +) -> None: """It answers 200 with HTML, so nothing before the magic-byte check notices.""" serve(b"sign in") with pytest.raises(ValueError, match="did not return a parquet file"): fetch_parquet("https://example.test/orders.parquet") -def test_a_failed_fetch_leaves_no_temporary_file(serve, monkeypatch): +def test_a_failed_fetch_leaves_no_temporary_file( + serve: Callable[..., FakeOpener], monkeypatch: pytest.MonkeyPatch +) -> None: created: list[str] = [] real_named_temp = tempfile.NamedTemporaryFile - def record(*args, **kwargs): + def record(*args: Any, **kwargs: Any) -> Any: handle = real_named_temp(*args, **kwargs) created.append(handle.name) return handle @@ -197,13 +219,13 @@ def record(*args, **kwargs): assert created and not any(Path(name).exists() for name in created) -def test_fetch_parquet_refuses_a_non_http_url(): +def test_fetch_parquet_refuses_a_non_http_url() -> None: """urlopen would honour file://, which would turn this into a local file reader.""" with pytest.raises(ValueError, match="http:// or https:// URL"): fetch_parquet("file:///etc/passwd") -def test_the_fetch_sets_a_user_agent(serve, parquet_file): +def test_the_fetch_sets_a_user_agent(serve: Callable[..., FakeOpener], parquet_file: Path) -> None: """Asset hosts 403 urllib's default, which the demo hit and worked around by hand.""" opener = serve(parquet_file.read_bytes()) Path(fetch_parquet("https://example.test/orders.parquet")).unlink() @@ -218,7 +240,9 @@ def test_the_fetch_sets_a_user_agent(serve, parquet_file): "address", ["169.254.169.254", "127.0.0.1", "10.0.0.1", "192.168.1.5", "::1", "::ffff:127.0.0.1"], ) -def test_a_url_resolving_to_a_private_address_is_refused(address, monkeypatch): +def test_a_url_resolving_to_a_private_address_is_refused( + address: str, monkeypatch: pytest.MonkeyPatch +) -> None: """A cloud metadata endpoint and an internal service are both one planted link away.""" monkeypatch.setattr( "hotdata_langchain.databases.socket.getaddrinfo", @@ -228,7 +252,9 @@ def test_a_url_resolving_to_a_private_address_is_refused(address, monkeypatch): fetch_parquet("https://internal.test/orders.parquet") -def test_every_resolved_address_is_checked_not_just_the_first(monkeypatch): +def test_every_resolved_address_is_checked_not_just_the_first( + monkeypatch: pytest.MonkeyPatch, +) -> None: """A host answering with one public and one private address must not pass on the public one.""" monkeypatch.setattr( "hotdata_langchain.databases.socket.getaddrinfo", @@ -238,7 +264,9 @@ def test_every_resolved_address_is_checked_not_just_the_first(monkeypatch): fetch_parquet("https://split.test/orders.parquet") -def test_a_private_host_is_allowed_when_the_deployment_says_so(monkeypatch, parquet_file): +def test_a_private_host_is_allowed_when_the_deployment_says_so( + monkeypatch: pytest.MonkeyPatch, parquet_file: Path +) -> None: """Loading from an internal store is legitimate; it just has to be chosen deliberately.""" monkeypatch.setattr( "hotdata_langchain.databases.socket.getaddrinfo", @@ -250,7 +278,7 @@ def test_a_private_host_is_allowed_when_the_deployment_says_so(monkeypatch, parq Path(path).unlink() -def test_a_redirect_to_a_private_address_is_refused(monkeypatch): +def test_a_redirect_to_a_private_address_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: """Checking only the URL as written is defeated by a public URL that 302s inwards.""" resolved = {"public.test": "93.184.216.34", "internal.test": "169.254.169.254"} monkeypatch.setattr( @@ -269,8 +297,8 @@ def test_a_redirect_to_a_private_address_is_refused(monkeypatch): ) -def test_an_unresolvable_host_says_so(monkeypatch): - def explode(host, port): +def test_an_unresolvable_host_says_so(monkeypatch: pytest.MonkeyPatch) -> None: + def explode(host: str, port: int) -> NoReturn: raise socket.gaierror("Name or service not known") monkeypatch.setattr("hotdata_langchain.databases.socket.getaddrinfo", explode) @@ -278,7 +306,9 @@ def explode(host, port): fetch_parquet("https://nowhere.test/orders.parquet") -def test_an_oversized_download_is_refused_from_the_declared_length(serve): +def test_an_oversized_download_is_refused_from_the_declared_length( + serve: Callable[..., FakeOpener], +) -> None: """Declared up front, so the transfer never starts.""" opener = serve(b"PAR1" + b"x" * 100, {"Content-Length": str(10 * 1024**3)}) with pytest.raises(ValueError, match=r"over the \d+-byte limit"): @@ -286,19 +316,21 @@ def test_an_oversized_download_is_refused_from_the_declared_length(serve): assert opener.requests, "the request was made, and the body was never read" -def test_an_oversized_download_is_refused_while_streaming(serve): +def test_an_oversized_download_is_refused_while_streaming(serve: Callable[..., FakeOpener]) -> None: """Content-Length is optional and can lie, so the bytes are counted regardless.""" serve(b"PAR1" + b"x" * 5000) with pytest.raises(ValueError, match="over the 1024-byte limit"): fetch_parquet("https://example.test/huge.parquet", max_bytes=1024) -def test_an_oversized_download_leaves_no_temporary_file(serve, monkeypatch): +def test_an_oversized_download_leaves_no_temporary_file( + serve: Callable[..., FakeOpener], monkeypatch: pytest.MonkeyPatch +) -> None: """The point of the cap is the disk, so a refusal that kept the bytes would defeat it.""" created: list[str] = [] real_named_temp = tempfile.NamedTemporaryFile - def record(*args, **kwargs): + def record(*args: Any, **kwargs: Any) -> Any: handle = real_named_temp(*args, **kwargs) created.append(handle.name) return handle @@ -310,7 +342,9 @@ def record(*args, **kwargs): assert created and not any(Path(name).exists() for name in created) -def test_a_file_inside_the_cap_is_kept(serve, parquet_file): +def test_a_file_inside_the_cap_is_kept( + serve: Callable[..., FakeOpener], parquet_file: Path +) -> None: payload = parquet_file.read_bytes() serve(payload, {"Content-Length": str(len(payload))}) path = fetch_parquet("https://example.test/orders.parquet", max_bytes=len(payload)) @@ -318,7 +352,13 @@ def test_a_file_inside_the_cap_is_kept(serve, parquet_file): Path(path).unlink() -def test_make_hotdata_tools(mock_client, sample_result, managed_db, databases_api, parquet_file): +def test_make_hotdata_tools( + mock_client: MagicMock, + sample_result: QueryResult, + managed_db: ManagedDatabase, + databases_api: MagicMock, + parquet_file: Path, +) -> None: mock_client.create_managed_database.return_value = ManagedDatabase( id="c1", description="sales", @@ -357,7 +397,7 @@ def test_make_hotdata_tools(mock_client, sample_result, managed_db, databases_ap ) -def test_every_tool_name_is_an_exported_constant(mock_client): +def test_every_tool_name_is_an_exported_constant(mock_client: MagicMock) -> None: """Consumers filter tools by name, so a name that only exists as a literal is API. Two agents against one workspace already need two different subsets, and each @@ -379,7 +419,7 @@ def test_every_tool_name_is_an_exported_constant(mock_client): assert {tool.name for tool in tools} == constants -def test_management_tools_can_be_left_out(mock_client): +def test_management_tools_can_be_left_out(mock_client: MagicMock) -> None: """An agent reading one fixed database cannot use them, and can only misuse them.""" tools = make_hotdata_tools(mock_client, management_tools=False) assert {tool.name for tool in tools} == { @@ -388,13 +428,13 @@ def test_management_tools_can_be_left_out(mock_client): } -def test_management_tools_are_on_by_default(mock_client): +def test_management_tools_are_on_by_default(mock_client: MagicMock) -> None: tools = {tool.name for tool in make_hotdata_tools(mock_client)} assert DEFAULT_CREATE_DATABASE_TOOL_NAME in tools assert DEFAULT_LOAD_TABLE_TOOL_NAME in tools -def test_the_sql_tool_stays_first_whichever_tools_are_included(mock_client): +def test_the_sql_tool_stays_first_whichever_tools_are_included(mock_client: MagicMock) -> None: """It is the one every agent needs; the ordering the model sees should not shift.""" kwarg_sets: tuple[dict[str, Any], ...] = ( {}, @@ -409,7 +449,7 @@ def test_the_sql_tool_stays_first_whichever_tools_are_included(mock_client): # --- Results that succeeded without doing what they said ---------------------------- -def test_sql_result_warns_about_an_uninterpreted_format_pattern(mock_client): +def test_sql_result_warns_about_an_uninterpreted_format_pattern(mock_client: MagicMock) -> None: """The measured failure: correct numbers labelled with the literal text 'YYYY-MM-DD'.""" payload = json.loads( execute_sql_json(mock_client, "SELECT to_char(start_time, 'YYYY-MM-DD') AS day FROM spans") @@ -417,18 +457,18 @@ def test_sql_result_warns_about_an_uninterpreted_format_pattern(mock_client): assert "'YYYY-MM-DD'" in payload["metadata"][CLIENT_WARNING_KEY] -def test_a_correct_query_carries_no_client_warning(mock_client): +def test_a_correct_query_carries_no_client_warning(mock_client: MagicMock) -> None: payload = json.loads(execute_sql_json(mock_client, "SELECT to_char(d, '%Y-%m-%d') FROM t")) assert CLIENT_WARNING_KEY not in payload["metadata"] -def test_capped_sql_result_says_how_many_rows_matched(mock_client): +def test_capped_sql_result_says_how_many_rows_matched(mock_client: MagicMock) -> None: payload = json.loads(execute_sql_json(mock_client, "SELECT n FROM t", max_rows=1)) assert len(payload["rows"]) == 1 assert "2" in payload["metadata"][CLIENT_WARNING_KEY] -def test_sql_description_states_the_row_cap(mock_client): +def test_sql_description_states_the_row_cap(mock_client: MagicMock) -> None: """A model that inferred the cap itself guessed the boundary and re-read four rows.""" tools = {tool.name: tool for tool in make_hotdata_tools(mock_client, max_rows=250)} description = tools[DEFAULT_SQL_TOOL_NAME].description or "" @@ -436,7 +476,7 @@ def test_sql_description_states_the_row_cap(mock_client): assert "row_count" in description -def test_tool_arguments_carry_descriptions(mock_client): +def test_tool_arguments_carry_descriptions(mock_client: MagicMock) -> None: """Until now the schema told the model a parameter's type and nothing else.""" tools = {tool.name: tool for tool in make_hotdata_tools(mock_client)} assert "description" in tools[DEFAULT_SQL_TOOL_NAME].args["sql"] @@ -444,13 +484,13 @@ def test_tool_arguments_carry_descriptions(mock_client): assert "description" in tools[DEFAULT_CREATE_DATABASE_TOOL_NAME].args["name"] -def test_tool_descriptions_are_unchanged_by_parsing_the_docstrings(mock_client): +def test_tool_descriptions_are_unchanged_by_parsing_the_docstrings(mock_client: MagicMock) -> None: """The explicit description= is what reaches the model, not the docstring summary.""" tools = {tool.name: tool for tool in make_hotdata_tools(mock_client)} assert (tools[DEFAULT_SQL_TOOL_NAME].description or "").startswith("Run a read-only SQL query") -def test_a_failing_query_still_reports_the_format_pattern(mock_client): +def test_a_failing_query_still_reports_the_format_pattern(mock_client: MagicMock) -> None: """Applying a Postgres template to a column returns only 'an internal server error'.""" mock_client.execute_sql.side_effect = RuntimeError("An internal server error occurred.") sql = "SELECT to_char(to_date(first_review, 'YYYY-MM-DD'), 'YYYY-MM') FROM listings" @@ -462,7 +502,7 @@ def test_a_failing_query_still_reports_the_format_pattern(mock_client): assert "An internal server error occurred." in message -def test_a_failure_with_nothing_to_add_is_raised_untouched(mock_client): +def test_a_failure_with_nothing_to_add_is_raised_untouched(mock_client: MagicMock) -> None: """Wrapping every failure would put this package's name on errors it knows nothing about.""" original = RuntimeError("Bad Request") mock_client.execute_sql.side_effect = original @@ -471,7 +511,7 @@ def test_a_failure_with_nothing_to_add_is_raised_untouched(mock_client): assert raised.value is original -def test_the_format_hint_survives_the_error_feedback_wrapper(mock_client): +def test_the_format_hint_survives_the_error_feedback_wrapper(mock_client: MagicMock) -> None: """It is the failure path the model reads, so the hint has to reach the payload.""" mock_client.execute_sql.side_effect = RuntimeError("An internal server error occurred.") tools = {tool.name: tool for tool in make_hotdata_tools(mock_client, handle_errors=True)} diff --git a/tests/test_update_changelog.py b/tests/test_update_changelog.py index c5cef0a..6e3f6a4 100644 --- a/tests/test_update_changelog.py +++ b/tests/test_update_changelog.py @@ -5,10 +5,11 @@ def _update_changelog_text(text: str, ver: str, date: str) -> str: path = Path(__file__).resolve().parents[1] / "scripts" / "update_changelog.py" spec = importlib.util.spec_from_file_location("update_changelog", path) + assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) - assert spec.loader is not None spec.loader.exec_module(module) - return module.update_changelog_text(text, ver, date) + updated: str = module.update_changelog_text(text, ver, date) + return updated HEADER = """# Changelog @@ -28,7 +29,7 @@ def _update_changelog_text(text: str, ver: str, date: str) -> str: """ -def test_empty_unreleased_inserts_version_without_duplicate_heading(): +def test_empty_unreleased_inserts_version_without_duplicate_heading() -> None: result = _update_changelog_text(HEADER, "0.1.2", "2026-05-20") assert result.count("## [Unreleased]") == 1 assert "## [0.1.2] - 2026-05-20" in result @@ -36,7 +37,7 @@ def test_empty_unreleased_inserts_version_without_duplicate_heading(): assert result.index("## [0.1.2]") < result.index("## [0.1.1]") -def test_populated_unreleased_moves_notes_into_new_section(): +def test_populated_unreleased_moves_notes_into_new_section() -> None: text = HEADER.replace( "## [Unreleased]\n\n", "## [Unreleased]\n\n### Added\n\n- New widget.\n\n", @@ -48,14 +49,14 @@ def test_populated_unreleased_moves_notes_into_new_section(): assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0] -def test_one_blank_line_separates_unreleased_from_the_new_section(): +def test_one_blank_line_separates_unreleased_from_the_new_section() -> None: """Every other section here is separated by one, and this ran on every release.""" result = _update_changelog_text(HEADER, "0.1.2", "2026-05-20") assert "## [Unreleased]\n\n## [0.1.2]" in result assert "\n\n\n" not in result -def test_notes_keep_one_blank_line_before_the_previous_release(): +def test_notes_keep_one_blank_line_before_the_previous_release() -> None: text = HEADER.replace( "## [Unreleased]\n\n", "## [Unreleased]\n\n### Added\n\n- New widget.\n\n\n", From 087b822dbab60379856573dfe53d33c21d0ea281 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Sat, 29 Aug 2026 22:50:02 +0530 Subject: [PATCH 2/2] ci: test the supported Python range, and gate ruff and mypy `requires-python` is `>=3.10` and CI ran 3.12 alone, so neither end of the range was exercised. The suite now runs on 3.10 through 3.14 with `fail-fast` off, and a second job fails the build on `ruff check`, `ruff format --check`, or `mypy`. All five versions pass. `test_the_agents_extra_provides_the_package_create_agent_lives_in` skips on 3.10, where `tomllib` is not in the standard library. Closes #42 --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++++++--- CHANGELOG.md | 6 ++++++ README.md | 9 +++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9629449..fd950f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,13 @@ permissions: jobs: test: - name: Test (Python 3.12) + name: Test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both ends of `requires-python`, and every version between. + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -24,10 +29,35 @@ jobs: enable-cache: true - name: Set up Python - run: uv python install 3.12 + run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --locked + run: uv sync --locked --python ${{ matrix.python-version }} - name: Test run: uv run pytest -v + + checks: + name: Lint and types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --locked --python 3.12 + + - name: Lint + run: uv run ruff check . + + - name: Format + run: uv run ruff format --check . + + - name: Types + run: uv run mypy diff --git a/CHANGELOG.md b/CHANGELOG.md index 307fd87..7b49691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 about. `SearchableColumn` and `verify_searchable_columns` are exported for callers assembling descriptions themselves. +- **The whole supported Python range is now tested, and lint and types are gated.** CI ran + `pytest` on 3.12 alone while `requires-python` claimed `>=3.10`, so neither end of the range + was exercised; it now runs the suite on 3.10 through 3.14 and fails the build on `ruff check`, + `ruff format --check`, or `mypy` in strict mode. The suite passes on every version. One test is + skipped on 3.10 only, where `tomllib` is not in the standard library. + ### Fixed - **The SQL tool no longer claims the registered column is the *only* indexed one.** It said diff --git a/README.md b/README.md index 9ebbb59..2811619 100644 --- a/README.md +++ b/README.md @@ -776,3 +776,12 @@ question with a stock LangChain retrieval chain over `HotdataVectorStore`. uv sync --locked uv run pytest ``` + +CI runs the suite on Python 3.10 through 3.14, and gates three more checks that a change has +to pass: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run mypy +```