Implement census analysis: read, count, summarise, write and CLI

Closes #11
Closes #12
Closes #13
Closes #14
Closes #15

Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Task: MIL-002#7
Task: MIL-002#8
This commit is contained in:
2026-10-13 15:37:51 +08:00
parent 259d017ad2
commit 7459711243
9 changed files with 364 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the squirrel.census module."""
import pathlib
from squirrel import census
from squirrel.constants import (
FILE_ENCODING,
FUR_COLOR_COLUMN,
FUR_COLORS,
SUMMARY_COLUMNS,
)
OTHER_COLUMN = "Unique Squirrel ID"
def _row(color: str) -> dict[str, str]:
return {FUR_COLOR_COLUMN: color}
def test_read_csv_returns_one_dict_per_row(tmp_path: pathlib.Path) -> None:
path = tmp_path / "census.csv"
path.write_text(
f"{OTHER_COLUMN},{FUR_COLOR_COLUMN}\n1,{FUR_COLORS[0]}\n2,\n",
encoding=FILE_ENCODING,
)
rows = census.read_csv(path)
assert rows == [
{OTHER_COLUMN: "1", FUR_COLOR_COLUMN: FUR_COLORS[0]},
{OTHER_COLUMN: "2", FUR_COLOR_COLUMN: ""},
]
def test_count_by_fur_color_counts_each_colour() -> None:
gray, cinnamon, black = FUR_COLORS
rows = [
_row(gray),
_row(gray),
_row(cinnamon),
_row(black),
_row(black),
_row(black),
]
counts = census.count_by_fur_color(rows)
assert counts == {gray: 2, cinnamon: 1, black: 3}
def test_count_by_fur_color_ignores_blank_and_unknown_colours() -> None:
rows = [_row(""), _row("White"), _row(FUR_COLORS[0].lower()), {OTHER_COLUMN: "1"}]
counts = census.count_by_fur_color(rows)
assert sum(counts.values()) == 0
def test_count_by_fur_color_includes_zero_counts_in_colour_order() -> None:
counts = census.count_by_fur_color([_row(FUR_COLORS[1])])
assert list(counts.items()) == [
(FUR_COLORS[0], 0),
(FUR_COLORS[1], 1),
(FUR_COLORS[2], 0),
]
def test_count_by_fur_color_uses_given_colours() -> None:
counts = census.count_by_fur_color([_row("White"), _row("White")], colors=["White"])
assert counts == {"White": 2}
def test_build_summary_keeps_order_of_counts() -> None:
counts = {"Black": 3, "Gray": 1, "Cinnamon": 2}
assert census.build_summary(counts) == [("Black", 3), ("Gray", 1), ("Cinnamon", 2)]
def test_write_csv_writes_header_then_rows(tmp_path: pathlib.Path) -> None:
path = tmp_path / "summary.csv"
summary = [(FUR_COLORS[0], 5), (FUR_COLORS[1], 0)]
census.write_csv(summary, path)
header = ",".join(SUMMARY_COLUMNS)
expected = f"{header}\n{FUR_COLORS[0]},5\n{FUR_COLORS[1]},0\n"
assert path.read_bytes().decode(FILE_ENCODING).replace("\r\n", "\n") == expected
+67
View File
@@ -0,0 +1,67 @@
"""Tests for the squirrel.cli module."""
import pathlib
import pytest
from squirrel import cli
from squirrel.constants import (
FILE_ENCODING,
FUR_COLOR_COLUMN,
FUR_COLORS,
SUMMARY_COLUMNS,
)
def _write_census(path: pathlib.Path) -> None:
gray, cinnamon, _black = FUR_COLORS
lines = [FUR_COLOR_COLUMN, gray, gray, cinnamon, ""]
path.write_text("\n".join(lines) + "\n", encoding=FILE_ENCODING)
def test_main_writes_summary_file_and_returns_zero(tmp_path: pathlib.Path) -> None:
census_path = tmp_path / "census.csv"
summary_path = tmp_path / "summary.csv"
_write_census(census_path)
status = cli.main([str(census_path), str(summary_path)])
assert status == 0
lines = summary_path.read_text(encoding=FILE_ENCODING).splitlines()
assert lines == [
",".join(SUMMARY_COLUMNS),
f"{FUR_COLORS[0]},2",
f"{FUR_COLORS[1]},1",
f"{FUR_COLORS[2]},0",
]
def test_main_prints_one_line_per_colour(
tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
) -> None:
census_path = tmp_path / "census.csv"
_write_census(census_path)
cli.main([str(census_path), str(tmp_path / "summary.csv")])
out_lines = capsys.readouterr().out.splitlines()
assert out_lines == [
f"{FUR_COLORS[0]}: 2",
f"{FUR_COLORS[1]}: 1",
f"{FUR_COLORS[2]}: 0",
]
def test_main_returns_2_and_writes_nothing_when_census_missing(
tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
) -> None:
missing = tmp_path / "missing.csv"
summary_path = tmp_path / "summary.csv"
status = cli.main([str(missing), str(summary_path)])
captured = capsys.readouterr()
assert status == 2
assert not summary_path.exists()
assert captured.err.strip() == f"Census file not found: {missing}"
assert captured.out == ""
+4
View File
@@ -31,6 +31,10 @@ def test_file_encoding_and_column_name_values() -> None:
assert constants.FUR_COLOR_COLUMN == "Primary Fur Color"
def test_exit_input_missing_is_2() -> None:
assert constants.EXIT_INPUT_MISSING == 2
def test_module_imports_only_standard_library() -> None:
source = pathlib.Path(constants.__file__).read_text(encoding="utf-8")
imported: set[str] = set()
+40
View File
@@ -0,0 +1,40 @@
"""Every module of the package imports only the standard library or itself."""
import ast
import pathlib
import sys
import squirrel
PACKAGE_NAME = squirrel.__name__
PACKAGE_DIR = pathlib.Path(squirrel.__file__).parent
ALLOWED = sys.stdlib_module_names | {PACKAGE_NAME}
def _imported_top_level_names(source: str) -> set[str]:
imported: set[str] = set()
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
imported.update(alias.name.split(".")[0] for alias in node.names)
elif (
isinstance(node, ast.ImportFrom)
and node.module is not None
and node.level == 0
):
imported.add(node.module.split(".")[0])
return imported
def test_package_has_modules_to_check() -> None:
assert list(PACKAGE_DIR.glob("*.py"))
def test_every_module_imports_only_standard_library_or_squirrel() -> None:
offenders: dict[str, set[str]] = {}
for module in PACKAGE_DIR.rglob("*.py"):
source = module.read_text(encoding="utf-8")
foreign = _imported_top_level_names(source) - ALLOWED
if foreign:
offenders[module.name] = foreign
assert offenders == {}
+21
View File
@@ -0,0 +1,21 @@
"""Check the counts of the real 2018 Central Park census file, when present."""
import pathlib
import pytest
from squirrel import census
from squirrel.constants import DEFAULT_INPUT_PATH, FUR_COLORS
REPO_ROOT = pathlib.Path(__file__).parents[1]
def test_real_census_counts_match_published_figures() -> None:
path = REPO_ROOT / DEFAULT_INPUT_PATH
if not path.is_file():
pytest.skip(f"census file not present: {path}")
counts = census.count_by_fur_color(census.read_csv(path))
gray, cinnamon, black = FUR_COLORS
assert counts == {gray: 2473, cinnamon: 392, black: 103}