You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@community.apache.org by rb...@apache.org on 2026/09/18 17:26:17 UTC

(comdev-metrics) branch main updated: Add unit test suite with pytest

This is an automated email from the ASF dual-hosted git repository.

rbowen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/comdev-metrics.git


The following commit(s) were added to refs/heads/main by this push:
     new 20801a5  Add unit test suite with pytest
20801a5 is described below

commit 20801a547fde59bc04f36becb270c32fe20e17c0
Author: Rich Bowen <rb...@rcbowen.com>
AuthorDate: Fri Sep 18 13:26:01 2026 -0400

    Add unit test suite with pytest
    
    33 pure unit tests covering:
    - collectors/cache.py: date helpers, cache load/save/invalidate
    - config.py: path resolution, constants, config loading
    - collectors/github_repos.py: repo classification, project map building
    - trends.py: quarter determination, trend computation
    - collectors/projects_apache_org.py: active project extraction
    - output/json_api.py: JSON file writing
    
    All tests are pure (no network, no mocking) — filesystem tests use
    pytest's tmp_path fixture.
    
    Also:
    - Add pytest>=8.0 as a dev dependency ([dependency-groups] in pyproject.toml)
    - Add tests/ to project structure in README
    - Add Testing section to README
    - Add cache.py to project structure listing
---
 README.md                         |  14 ++++++
 pyproject.toml                    |   3 ++
 tests/test_cache.py               |  94 +++++++++++++++++++++++++++++++++++
 tests/test_config.py              |  70 ++++++++++++++++++++++++++
 tests/test_github_repos.py        |  80 ++++++++++++++++++++++++++++++
 tests/test_json_api.py            |  31 ++++++++++++
 tests/test_projects_apache_org.py |  48 ++++++++++++++++++
 tests/test_trends.py              | 101 ++++++++++++++++++++++++++++++++++++++
 8 files changed, 441 insertions(+)

diff --git a/README.md b/README.md
index 57b9743..a841563 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,16 @@ cd site && python3 -m http.server 8888
 # Open http://localhost:8888
 ```
 
+## Testing
+
+```bash
+# Install dev dependencies (includes pytest)
+uv sync --all-groups
+
+# Run all tests
+uv run pytest -v
+```
+
 ## What It Does
 
 1. **Fetches** foundation-wide data from projects.apache.org JSON files (committees, rosters, releases, podlings, repositories)
@@ -124,6 +134,7 @@ comdev-metrics/
 │   ├── config.py           # Config loading (YAML, multi-path lookup)
 │   ├── trends.py           # Deterministic activity trend classification
 │   ├── collectors/
+│   │   ├── cache.py                 # Shared caching utilities (load/save/invalidate)
 │   │   ├── mailing_lists.py         # Pony Mail Foal API + caching
 │   │   ├── git_activity.py          # GitHub API + SVN log collector + caching
 │   │   ├── github_repos.py          # Repo inventory + project classification
@@ -137,6 +148,7 @@ comdev-metrics/
 │   ├── about.html          # Data sources and methodology documentation
 │   └── data/               # Generated JSON + cache (git-ignored)
 │       ├── _cache/
+│       │   ├── doap-warnings.json
 │       │   ├── mailing_lists/  # Per-project mailing list cache
 │       │   ├── git/            # Per-project git activity cache
 │       │   ├── committees.json
@@ -150,6 +162,7 @@ comdev-metrics/
 │       └── <project>_git.json  # Git activity data
 ├── DATA_SOURCES.md         # Where all the data comes from
 ├── PLAN.md                 # Execution plan and milestones
+├── tests/                  # Unit tests (pytest)
 └── LICENSE                 # Apache License 2.0
 ```
 
@@ -229,6 +242,7 @@ For local testing:
 cp config.example.yml config.yml
 cp .secrets.example .secrets   # add your GitHub token
 uv run asfmetrics --project comdev
+uv run pytest -v               # run tests
 ```
 
 **Planned**: Serve at `https://projects.apache.org/metrics` (via Alias or rewrite from the ComDev VM).
diff --git a/pyproject.toml b/pyproject.toml
index 3be722f..1164190 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,6 +9,9 @@ dependencies = [
     "pyyaml>=6.0",
 ]
 
+[dependency-groups]
+dev = ["pytest>=8.0"]
+
 [project.scripts]
 asfmetrics = "asfmetrics.cli:main"
 
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..309bbba
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,94 @@
+"""Tests for collectors/cache.py — shared caching utilities."""
+
+from datetime import datetime
+from pathlib import Path
+
+from asfmetrics.collectors.cache import (
+    cache_is_current,
+    collector_cache_dir,
+    current_month_str,
+    invalidate,
+    load_cache,
+    save_cache,
+    twelve_months_ago,
+    twelve_months_ago_str,
+)
+
+
+# --- Date helpers ---
+
+def test_current_month_str_format():
+    result = current_month_str()
+    assert len(result) == 7  # YYYY-MM
+    assert result[4] == "-"
+    year, month = result.split("-")
+    assert 2020 <= int(year) <= 2100
+    assert 1 <= int(month) <= 12
+
+
+def test_twelve_months_ago_returns_datetime():
+    result = twelve_months_ago()
+    assert isinstance(result, datetime)
+    now = datetime.now()
+    assert result.year == now.year - 1
+    assert result.month == now.month
+
+
+def test_twelve_months_ago_str_format():
+    result = twelve_months_ago_str()
+    assert len(result) == 7
+    assert result[4] == "-"
+
+
+def test_twelve_months_ago_str_matches_datetime():
+    dt = twelve_months_ago()
+    s = twelve_months_ago_str()
+    assert s == f"{dt.year}-{dt.month:02d}"
+
+
+# --- cache_is_current ---
+
+def test_cache_is_current_today():
+    today = datetime.now().strftime("%Y-%m-%d")
+    assert cache_is_current({"_fetched_at": today}) is True
+
+
+def test_cache_is_current_yesterday():
+    assert cache_is_current({"_fetched_at": "2020-01-01"}) is False
+
+
+def test_cache_is_current_missing():
+    assert cache_is_current({}) is False
+
+
+# --- Directory + file operations ---
+
+def test_collector_cache_dir_creates_subdir(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    result = collector_cache_dir(config, "test_collector")
+    assert result.exists()
+    assert result.name == "test_collector"
+    assert result.parent.name == "_cache"
+
+
+def test_save_and_load_cache(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    data = {"lists": ["dev", "user"], "count": 42}
+    save_cache("myproject", data, config, "test_collector")
+    loaded = load_cache("myproject", config, "test_collector")
+    assert loaded == data
+
+
+def test_load_cache_missing(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    assert load_cache("nonexistent", config, "test_collector") is None
+
+
+def test_invalidate_clears_files(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    save_cache("proj_a", {"a": 1}, config, "test_collector")
+    save_cache("proj_b", {"b": 2}, config, "test_collector")
+    cache_dir = collector_cache_dir(config, "test_collector")
+    assert len(list(cache_dir.glob("*.json"))) == 2
+    invalidate(config, "test_collector")
+    assert len(list(cache_dir.glob("*.json"))) == 0
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..49958d7
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,70 @@
+"""Tests for config.py — configuration loading and path helpers."""
+
+from pathlib import Path
+
+import pytest
+
+from asfmetrics.config import (
+    DEFAULT_JSON_DIR,
+    CACHE_SUBDIR,
+    STATE_SUBDIR,
+    PROJECT_MAP_FILE,
+    get_json_dir,
+    get_cache_dir,
+    get_state_dir,
+    load_config,
+)
+
+
+# --- Path helpers ---
+
+def test_get_json_dir_from_config(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "out")}}
+    result = get_json_dir(config)
+    assert result == tmp_path / "out"
+    assert result.exists()
+
+
+def test_get_json_dir_default(tmp_path, monkeypatch):
+    """With no config key, falls back to DEFAULT_JSON_DIR."""
+    monkeypatch.chdir(tmp_path)
+    result = get_json_dir({})
+    assert result == Path(DEFAULT_JSON_DIR)
+
+
+def test_get_cache_dir(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    result = get_cache_dir(config)
+    assert result == tmp_path / "data" / CACHE_SUBDIR
+    assert result.exists()
+
+
+def test_get_state_dir(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    result = get_state_dir(config)
+    assert result == tmp_path / "data" / STATE_SUBDIR
+    assert result.exists()
+
+
+# --- Constants ---
+
+def test_constants_are_strings():
+    assert isinstance(DEFAULT_JSON_DIR, str)
+    assert isinstance(CACHE_SUBDIR, str)
+    assert isinstance(STATE_SUBDIR, str)
+    assert isinstance(PROJECT_MAP_FILE, str)
+    assert PROJECT_MAP_FILE.endswith(".json")
+
+
+# --- load_config ---
+
+def test_load_config_from_file(tmp_path):
+    config_file = tmp_path / "config.yml"
+    config_file.write_text("projects:\n  - kafka\n  - airflow\n")
+    result = load_config(config_file)
+    assert result["projects"] == ["kafka", "airflow"]
+
+
+def test_load_config_missing_raises():
+    with pytest.raises(FileNotFoundError):
+        load_config(Path("/nonexistent/config.yml"))
diff --git a/tests/test_github_repos.py b/tests/test_github_repos.py
new file mode 100644
index 0000000..73d8c7c
--- /dev/null
+++ b/tests/test_github_repos.py
@@ -0,0 +1,80 @@
+"""Tests for collectors/github_repos.py — repo classification."""
+
+from asfmetrics.collectors.github_repos import classify_repo, build_project_map
+
+
+# --- classify_repo ---
+
+def test_classify_simple_repo():
+    result = classify_repo("kafka")
+    assert result == {"project": "kafka", "sub_repo": None, "incubating": False}
+
+
+def test_classify_sub_repo():
+    result = classify_repo("kafka-site")
+    assert result == {"project": "kafka", "sub_repo": "site", "incubating": False}
+
+
+def test_classify_incubator_repo():
+    result = classify_repo("incubator-ponymail")
+    assert result == {"project": "ponymail", "sub_repo": None, "incubating": True}
+
+
+def test_classify_incubator_sub_repo():
+    result = classify_repo("incubator-ponymail-foal")
+    assert result == {"project": "ponymail", "sub_repo": "foal", "incubating": True}
+
+
+def test_classify_hyphenated_project():
+    """empire-db is a project name with a hyphen — split only on first hyphen."""
+    result = classify_repo("empire-db")
+    assert result["project"] == "empire"
+    assert result["sub_repo"] == "db"
+
+
+# --- build_project_map ---
+
+def test_build_project_map_groups_repos():
+    repos = [
+        {"name": "kafka", "fork": False, "archived": False},
+        {"name": "kafka-site", "fork": False, "archived": False},
+        {"name": "kafka-python", "fork": False, "archived": False},
+    ]
+    result = build_project_map(repos)
+    assert "kafka" in result
+    assert len(result["kafka"]["repos"]) == 3
+
+
+def test_build_project_map_skips_forks():
+    repos = [
+        {"name": "kafka", "fork": False, "archived": False},
+        {"name": "some-fork", "fork": True, "archived": False},
+    ]
+    result = build_project_map(repos)
+    assert "some" not in result
+
+
+def test_build_project_map_detects_incubating():
+    repos = [
+        {"name": "incubator-xyz", "fork": False, "archived": False},
+    ]
+    result = build_project_map(repos)
+    assert result["xyz"]["incubating"] is True
+
+
+def test_build_project_map_detects_all_archived():
+    repos = [
+        {"name": "oldproject", "fork": False, "archived": True},
+        {"name": "oldproject-docs", "fork": False, "archived": True},
+    ]
+    result = build_project_map(repos)
+    assert result["oldproject"]["archived"] is True
+
+
+def test_build_project_map_not_archived_if_any_active():
+    repos = [
+        {"name": "myproj", "fork": False, "archived": False},
+        {"name": "myproj-old", "fork": False, "archived": True},
+    ]
+    result = build_project_map(repos)
+    assert result["myproj"]["archived"] is False
diff --git a/tests/test_json_api.py b/tests/test_json_api.py
new file mode 100644
index 0000000..26d5117
--- /dev/null
+++ b/tests/test_json_api.py
@@ -0,0 +1,31 @@
+"""Tests for output/json_api.py — JSON file writing."""
+
+import json
+
+from asfmetrics.output.json_api import write_json
+
+
+def test_write_json_creates_file(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    stats = {"project": "kafka", "active_lists": [{"list_name": "dev", "messages": 100}]}
+    result = write_json("kafka", stats, config)
+    assert result.exists()
+    assert result.name == "kafka.json"
+    with open(result) as f:
+        loaded = json.load(f)
+    assert loaded == stats
+
+
+def test_write_json_creates_directory(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "deep" / "nested" / "data")}}
+    write_json("test", {"x": 1}, config)
+    assert (tmp_path / "deep" / "nested" / "data" / "test.json").exists()
+
+
+def test_write_json_overwrites(tmp_path):
+    config = {"output": {"json_dir": str(tmp_path / "data")}}
+    write_json("proj", {"version": 1}, config)
+    write_json("proj", {"version": 2}, config)
+    with open(tmp_path / "data" / "proj.json") as f:
+        loaded = json.load(f)
+    assert loaded["version"] == 2
diff --git a/tests/test_projects_apache_org.py b/tests/test_projects_apache_org.py
new file mode 100644
index 0000000..e6b4c08
--- /dev/null
+++ b/tests/test_projects_apache_org.py
@@ -0,0 +1,48 @@
+"""Tests for collectors/projects_apache_org.py — foundation data helpers."""
+
+from asfmetrics.collectors.projects_apache_org import extract_active_projects
+
+
+# --- extract_active_projects ---
+
+def test_extract_from_committees_list():
+    data = {
+        "committees": [
+            {"id": "kafka"},
+            {"id": "airflow"},
+            {"id": "comdev"},
+        ],
+        "podlings": {},
+    }
+    result = extract_active_projects(data)
+    assert result == ["airflow", "comdev", "kafka"]  # sorted
+
+
+def test_extract_includes_current_podlings():
+    data = {
+        "committees": [{"id": "kafka"}],
+        "podlings": {
+            "Pekko": {"status": "current"},
+            "OldThing": {"status": "retired"},
+        },
+    }
+    result = extract_active_projects(data)
+    assert "pekko" in result
+    assert "oldthing" not in result
+    assert "kafka" in result
+
+
+def test_extract_empty_data():
+    data = {"committees": [], "podlings": {}}
+    result = extract_active_projects(data)
+    assert result == []
+
+
+def test_extract_deduplicates():
+    """If a project appears in both committees and podlings, no duplicates."""
+    data = {
+        "committees": [{"id": "spark"}],
+        "podlings": {"Spark": {"status": "current"}},
+    }
+    result = extract_active_projects(data)
+    assert result.count("spark") == 1
diff --git a/tests/test_trends.py b/tests/test_trends.py
new file mode 100644
index 0000000..35a8b2e
--- /dev/null
+++ b/tests/test_trends.py
@@ -0,0 +1,101 @@
+"""Tests for trends.py — activity trend classification logic."""
+
+from datetime import date
+
+from asfmetrics.trends import _determine_quarters, _compute_trend
+
+
+# --- _determine_quarters ---
+
+def test_determine_quarters_mid_month():
+    """Jul 10 2026: recent = Apr-Jun, prior = Jan-Mar."""
+    result = _determine_quarters(today=date(2026, 7, 10))
+    assert result["prior_months"] == ["2026-01", "2026-02", "2026-03"]
+    assert result["recent_months"] == ["2026-04", "2026-05", "2026-06"]
+
+
+def test_determine_quarters_jan():
+    """Jan 15 2026: recent = Oct-Dec 2025, prior = Jul-Sep 2025."""
+    result = _determine_quarters(today=date(2026, 1, 15))
+    assert result["prior_months"] == ["2025-07", "2025-08", "2025-09"]
+    assert result["recent_months"] == ["2025-10", "2025-11", "2025-12"]
+
+
+def test_determine_quarters_first_of_month():
+    """Mar 1 2026: current month (Mar) excluded. recent = Dec-Feb, prior = Sep-Nov."""
+    result = _determine_quarters(today=date(2026, 3, 1))
+    assert result["recent_months"] == ["2025-12", "2026-01", "2026-02"]
+    assert result["prior_months"] == ["2025-09", "2025-10", "2025-11"]
+
+
+def test_determine_quarters_has_labels():
+    result = _determine_quarters(today=date(2026, 7, 10))
+    assert "prior_label" in result
+    assert "recent_label" in result
+    assert "window_description" in result
+    assert "2026" in result["recent_label"]
+
+
+# --- _compute_trend ---
+
+def test_compute_trend_growth():
+    data = {"2026-01": 10, "2026-02": 10, "2026-03": 10,
+            "2026-04": 15, "2026-05": 15, "2026-06": 15}
+    pct, recent, prior = _compute_trend(
+        data,
+        recent_months=["2026-04", "2026-05", "2026-06"],
+        prior_months=["2026-01", "2026-02", "2026-03"],
+    )
+    assert prior == 30
+    assert recent == 45
+    assert pct == 50.0  # (45-30)/30 * 100
+
+
+def test_compute_trend_decline():
+    data = {"2026-01": 20, "2026-02": 20, "2026-03": 20,
+            "2026-04": 10, "2026-05": 10, "2026-06": 10}
+    pct, recent, prior = _compute_trend(
+        data,
+        recent_months=["2026-04", "2026-05", "2026-06"],
+        prior_months=["2026-01", "2026-02", "2026-03"],
+    )
+    assert prior == 60
+    assert recent == 30
+    assert pct == -50.0
+
+
+def test_compute_trend_zero_prior():
+    """If prior is zero, percent change should be None."""
+    data = {"2026-04": 10, "2026-05": 10, "2026-06": 10}
+    pct, recent, prior = _compute_trend(
+        data,
+        recent_months=["2026-04", "2026-05", "2026-06"],
+        prior_months=["2026-01", "2026-02", "2026-03"],
+    )
+    assert prior == 0
+    assert recent == 30
+    assert pct is None
+
+
+def test_compute_trend_missing_months():
+    """Missing months should be treated as 0."""
+    data = {"2026-01": 100}
+    pct, recent, prior = _compute_trend(
+        data,
+        recent_months=["2026-04", "2026-05", "2026-06"],
+        prior_months=["2026-01", "2026-02", "2026-03"],
+    )
+    assert prior == 100
+    assert recent == 0
+    assert pct == -100.0
+
+
+def test_compute_trend_no_change():
+    data = {"2026-01": 10, "2026-02": 10, "2026-03": 10,
+            "2026-04": 10, "2026-05": 10, "2026-06": 10}
+    pct, recent, prior = _compute_trend(
+        data,
+        recent_months=["2026-04", "2026-05", "2026-06"],
+        prior_months=["2026-01", "2026-02", "2026-03"],
+    )
+    assert pct == 0.0