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