This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch feat/oink-community-content in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit 47100479bdc63492b52ee0539872c4f36257f0ae Author: dark <[email protected]> AuthorDate: Fri Sep 4 19:03:17 2026 +0800 feat(community): add deterministic ASF roster - derive PMC and Committers from authoritative ASF public data\n- preserve the last-good bundle across refresh failures\n- validate roles, mappings, staleness, and local avatars\n- cover ordering, drift, mapping, and recovery contracts --- data/community/github-map.json | 4 + data/community/roster.json | 209 ++++++++++++++++++++++++ scripts/community_roster.py | 338 +++++++++++++++++++++++++++++++++++++++ scripts/test_community_roster.py | 53 ++++++ 4 files changed, 604 insertions(+) diff --git a/data/community/github-map.json b/data/community/github-map.json new file mode 100644 index 000000000..7529e5a32 --- /dev/null +++ b/data/community/github-map.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "mappings": {} +} diff --git a/data/community/roster.json b/data/community/roster.json new file mode 100644 index 000000000..ee6197891 --- /dev/null +++ b/data/community/roster.json @@ -0,0 +1,209 @@ +{ + "schema_version": 1, + "project": "hugegraph", + "retrieved_at": "2026-09-04T11:03:16Z", + "source": { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", + "chair": "jermy", + "owners": [ + "hxd", + "jermy", + "jin", + "lidongdai", + "linary", + "liyu", + "ming", + "ningjiang", + "panjuan", + "vaughn", + "vgalaxies", + "zhaocong" + ], + "members": [ + "guoshoujing", + "hxd", + "jermy", + "jin", + "jsong010123", + "leizou", + "lidongdai", + "linary", + "liuxiaocs", + "liyu", + "ming", + "ningjiang", + "panjuan", + "pengjunzhi", + "spica", + "vaughn", + "vgalaxies", + "vichayturen", + "wangjing", + "yangjiaqi", + "zhangyi89817", + "zhaocong" + ] + }, + "roles": { + "pmc": [ + { + "asf_id": "jermy", + "name": "Jermy Li", + "initials": "JL", + "chair": true, + "profile_url": "https://people.apache.org/phonebook.html?uid=jermy" + }, + { + "asf_id": "zhaocong", + "name": "Cong Zhao", + "initials": "CZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhaocong" + }, + { + "asf_id": "jin", + "name": "Imba Jin", + "initials": "IJ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=jin" + }, + { + "asf_id": "panjuan", + "name": "Juan Pan", + "initials": "JP", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=panjuan" + }, + { + "asf_id": "lidongdai", + "name": "Lidong Dai", + "initials": "LD", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=lidongdai" + }, + { + "asf_id": "linary", + "name": "NingRui Li", + "initials": "NL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=linary" + }, + { + "asf_id": "ming", + "name": "Simon", + "initials": "S", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=ming" + }, + { + "asf_id": "ningjiang", + "name": "Willem Ning Jiang", + "initials": "WN", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=ningjiang" + }, + { + "asf_id": "hxd", + "name": "Xiangdong Huang", + "initials": "XH", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=hxd" + }, + { + "asf_id": "vaughn", + "name": "Yan Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vaughn" + }, + { + "asf_id": "liyu", + "name": "Yu Li", + "initials": "YL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=liyu" + }, + { + "asf_id": "vgalaxies", + "name": "Yuchen Ding", + "initials": "YD", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vgalaxies" + } + ], + "committers": [ + { + "asf_id": "yangjiaqi", + "name": "Jacky Yang", + "initials": "JY", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=yangjiaqi" + }, + { + "asf_id": "jsong010123", + "name": "Jason", + "initials": "J", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=jsong010123" + }, + { + "asf_id": "wangjing", + "name": "Jing Wang", + "initials": "JW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=wangjing" + }, + { + "asf_id": "pengjunzhi", + "name": "Junzhi Peng", + "initials": "JP", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=pengjunzhi" + }, + { + "asf_id": "vichayturen", + "name": "Kaiyichen Wei", + "initials": "KW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vichayturen" + }, + { + "asf_id": "leizou", + "name": "Lei Zou", + "initials": "LZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=leizou" + }, + { + "asf_id": "guoshoujing", + "name": "Shoujing Guo", + "initials": "SG", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=guoshoujing" + }, + { + "asf_id": "liuxiaocs", + "name": "Xiao Liu", + "initials": "XL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=liuxiaocs" + }, + { + "asf_id": "zhangyi89817", + "name": "Yi Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhangyi89817" + }, + { + "asf_id": "spica", + "name": "Zhe Wang", + "initials": "ZW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=spica" + } + ] + } +} diff --git a/scripts/community_roster.py b/scripts/community_roster.py new file mode 100644 index 000000000..ff9d2bb36 --- /dev/null +++ b/scripts/community_roster.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Refresh and validate the offline Apache HugeGraph community roster.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import pathlib +import shutil +import struct +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DATA_DIR = ROOT / "data" / "community" +ROSTER_PATH = DATA_DIR / "roster.json" +MAP_PATH = DATA_DIR / "github-map.json" +AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars" +PROJECT = "hugegraph" +SCHEMA_VERSION = 1 +SOURCES = { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", +} + + +class RosterError(ValueError): + pass + + +def _read_json(path: pathlib.Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RosterError(f"{path.relative_to(ROOT)}: invalid JSON: {exc}") from exc + + +def _fetch_json(url: str) -> dict: + request = urllib.request.Request(url, headers={"User-Agent": "apache-hugegraph-doc-community-roster/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 200: + raise RosterError(f"{url}: HTTP {response.status}") + return json.load(response) + + +def _person_name(people: dict, asf_id: str) -> str: + record = people.get("people", {}).get(asf_id) + name = record.get("name") if isinstance(record, dict) else None + if isinstance(name, list): + name = name[0] if name else "" + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"people source has no public name for ASF ID {asf_id!r}") + return name.strip() + + +def _initials(name: str) -> str: + parts = [part for part in name.replace("-", " ").split() if part] + return "".join(part[0].upper() for part in parts[:2]) or "?" + + +def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict: + if data.get("schema_version") != SCHEMA_VERSION: + raise RosterError("github-map.json: schema_version must be 1") + mappings = data.get("mappings") + if not isinstance(mappings, dict): + raise RosterError("github-map.json: mappings must be an object") + logins: set[str] = set() + user_ids: set[int] = set() + for asf_id, mapping in mappings.items(): + if roster_ids is not None and asf_id not in roster_ids: + raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}") + if not isinstance(mapping, dict): + raise RosterError(f"github-map.json: mapping for {asf_id!r} must be an object") + login, user_id = mapping.get("login"), mapping.get("user_id") + if not isinstance(login, str) or not login.strip() or login != login.strip(): + raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed login") + if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0: + raise RosterError(f"github-map.json: {asf_id!r} needs a positive numeric user_id") + if login.casefold() in logins: + raise RosterError(f"github-map.json: duplicate GitHub login {login!r}") + if user_id in user_ids: + raise RosterError(f"github-map.json: duplicate GitHub user_id {user_id}") + logins.add(login.casefold()) + user_ids.add(user_id) + return mappings + + +def _webp_dimensions(raw: bytes) -> tuple[int, int]: + if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + chunk = raw[12:16] + if chunk == b"VP8X": + return 1 + int.from_bytes(raw[24:27], "little"), 1 + int.from_bytes(raw[27:30], "little") + if chunk == b"VP8L": + bits = int.from_bytes(raw[21:25], "little") + return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF) + if chunk == b"VP8 ": + marker = raw.find(b"\x9d\x01\x2a", 20) + if marker < 0 or marker + 7 > len(raw): + raise RosterError("avatar has an invalid VP8 frame") + width, height = struct.unpack_from("<HH", raw, marker + 3) + return width & 0x3FFF, height & 0x3FFF + raise RosterError(f"avatar uses unsupported WebP chunk {chunk!r}") + + +def _avatar_bytes(user_id: int) -> bytes: + request = urllib.request.Request( + f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4", + headers={"Accept": "image/webp", "User-Agent": "apache-hugegraph-doc-community-roster/1"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read() + if _webp_dimensions(raw) != (128, 128): + raise RosterError(f"GitHub avatar for numeric user ID {user_id} is not 128x128 WebP") + return raw + + +def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict: + member = { + "asf_id": asf_id, + "name": name, + "initials": _initials(name), + "chair": chair, + "profile_url": f"https://people.apache.org/phonebook.html?uid={asf_id}", + } + if mapping: + member["github"] = {"login": mapping["login"], "user_id": mapping["user_id"]} + return member + + +def build_roster(committee_data: dict, projects_data: dict, people_data: dict, mapping_data: dict) -> dict: + project = projects_data.get("projects", {}).get(PROJECT) + committee = committee_data.get("committees", {}).get(PROJECT) + if not isinstance(project, dict) or not isinstance(committee, dict): + raise RosterError("ASF sources do not contain the HugeGraph project") + owners, members = project.get("owners"), project.get("members") + chair_map, committee_roster = committee.get("chair"), committee.get("roster") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("LDAP project owners/members must be arrays") + if not isinstance(chair_map, dict) or len(chair_map) != 1: + raise RosterError("committee source must name exactly one Chair") + if not isinstance(committee_roster, dict): + raise RosterError("committee roster must be an object") + owner_ids, member_ids = set(owners), set(members) + chair = next(iter(chair_map)) + if not owner_ids <= member_ids: + raise RosterError("LDAP owners must be a subset of members") + if chair not in owner_ids: + raise RosterError("Chair must be an LDAP owner") + if owner_ids != set(committee_roster): + raise RosterError("committee roster and LDAP owners disagree") + mappings = _validate_mapping(mapping_data, member_ids) + names = {asf_id: _person_name(people_data, asf_id) for asf_id in member_ids} + pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: names[item].casefold()) + committer_ids = sorted(member_ids - owner_ids, key=lambda item: names[item].casefold()) + return { + "schema_version": SCHEMA_VERSION, + "project": PROJECT, + "retrieved_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source": { + **SOURCES, + "chair": chair, + "owners": sorted(owner_ids), + "members": sorted(member_ids), + }, + "roles": { + "pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in pmc_ids], + "committers": [_member(i, names[i], False, mappings.get(i)) for i in committer_ids], + }, + } + + +def _install_avatars(candidate: dict, target: pathlib.Path) -> None: + target.mkdir(parents=True, exist_ok=True) + for role in ("pmc", "committers"): + for member in candidate["roles"][role]: + github = member.get("github") + if not github: + continue + raw = _avatar_bytes(github["user_id"]) + digest = hashlib.sha256(raw).hexdigest() + path = target / f"{digest}.webp" + if not path.exists(): + path.write_bytes(raw) + member["avatar"] = f"/img/community/avatars/{path.name}" + member["profile_url"] = f"https://github.com/{github['login']}" + + +def validate_bundle(warn_after_days: int) -> list[str]: + roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH) + if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") != PROJECT: + raise RosterError("roster.json: unsupported schema_version or project") + roles, source = roster.get("roles"), roster.get("source") + if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}: + raise RosterError("roster.json: roles must contain only pmc and committers") + if not isinstance(source, dict): + raise RosterError("roster.json: source must be an object") + for key, url in SOURCES.items(): + if source.get(key) != url: + raise RosterError(f"roster.json: source.{key} is not authoritative") + owners, members, chair = source.get("owners"), source.get("members"), source.get("chair") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("roster.json: source owners/members must be arrays") + if owners != sorted(set(owners)) or members != sorted(set(members)): + raise RosterError("roster.json: source owners/members must be sorted and unique") + if not set(owners) <= set(members) or chair not in owners: + raise RosterError("roster.json: invalid owners/members/Chair relationship") + pmc, committers = roles["pmc"], roles["committers"] + if not isinstance(pmc, list) or not isinstance(committers, list) or not pmc: + raise RosterError("roster.json: invalid role arrays") + people = pmc + committers + ids = [person.get("asf_id") for person in people if isinstance(person, dict)] + if len(ids) != len(people) or len(ids) != len(set(ids)) or set(ids) != set(members): + raise RosterError("roster.json: members must match unique source ASF IDs") + if {p["asf_id"] for p in pmc} != set(owners): + raise RosterError("roster.json: PMC must equal owners") + if {p["asf_id"] for p in committers} != set(members) - set(owners): + raise RosterError("roster.json: Committers must equal members minus owners") + chairs = [person for person in people if person.get("chair") is True] + if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != chairs[0]: + raise RosterError("roster.json: unique Chair must be first in PMC") + for role, entries in roles.items(): + tail = entries[1:] if role == "pmc" else entries + if [p.get("name", "").casefold() for p in tail] != sorted(p.get("name", "").casefold() for p in tail): + raise RosterError(f"roster.json: {role} must be sorted by public name casefold") + for person in entries: + if any(key not in person for key in ("asf_id", "name", "initials", "chair", "profile_url")): + raise RosterError(f"roster.json: incomplete member {person!r}") + mappings = _validate_mapping(mapping, set(ids)) + for person in people: + expected, avatar = mappings.get(person["asf_id"]), person.get("avatar") + if expected != person.get("github"): + raise RosterError(f"roster.json: GitHub mapping drift for {person['asf_id']!r}") + if expected: + if not isinstance(avatar, str) or not avatar.startswith("/img/community/avatars/"): + raise RosterError(f"roster.json: mapped member {person['asf_id']!r} needs a local avatar") + filename = pathlib.PurePosixPath(avatar).name + if len(filename) != 69 or not filename.endswith(".webp"): + raise RosterError(f"roster.json: avatar filename must be a SHA-256 digest") + raw = (ROOT / "static" / avatar.lstrip("/")).read_bytes() + if hashlib.sha256(raw).hexdigest() != filename[:-5] or _webp_dimensions(raw) != (128, 128): + raise RosterError(f"roster.json: invalid avatar {avatar}") + if person["profile_url"] != f"https://github.com/{expected['login']}": + raise RosterError(f"roster.json: mapped profile URL mismatch") + elif avatar: + raise RosterError(f"roster.json: unmapped member has an avatar") + try: + retrieved = dt.datetime.fromisoformat(roster["retrieved_at"].replace("Z", "+00:00")) + except (KeyError, TypeError, ValueError) as exc: + raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") from exc + now = dt.datetime.now(dt.timezone.utc) + if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5): + raise RosterError("roster.json: retrieved_at is in the future or lacks a timezone") + age = now - retrieved + return [f"community roster is {age.days} days old (threshold: {warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else [] + + +def validate_rendered_outputs() -> None: + with tempfile.TemporaryDirectory(prefix="hugegraph-community-site-") as destination: + result = subprocess.run(["hugo", "--quiet", "--destination", destination], cwd=ROOT, text=True, capture_output=True) + if result.returncode: + raise RosterError(f"Hugo render failed:\n{result.stderr.strip()}") + expected = { + "community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "community/index.md": ("## Project members", "### PMC", "### Committers"), + "cn/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"), + } + urls = [p["profile_url"] for role in ("pmc", "committers") for p in _read_json(ROSTER_PATH)["roles"][role]] + for relative, markers in expected.items(): + path = pathlib.Path(destination) / relative + if not path.is_file(): + raise RosterError(f"rendered output is missing {relative}") + rendered = path.read_text(encoding="utf-8") + if any(marker not in rendered for marker in markers): + raise RosterError(f"rendered output {relative} is missing Community markers") + positions = [rendered.find(url.replace("&", "&") if relative.endswith(".html") else url) for url in urls] + if any(position < 0 for position in positions) or positions != sorted(positions): + raise RosterError(f"rendered output {relative} has role/link parity drift") + + +def refresh() -> None: + source_data = {key: _fetch_json(url) for key, url in SOURCES.items()} + candidate = build_roster(source_data["committee"], source_data["projects"], source_data["people"], _read_json(MAP_PATH)) + with tempfile.TemporaryDirectory(prefix=".community-refresh-", dir=DATA_DIR) as work: + work_path = pathlib.Path(work) + candidate_avatars = work_path / "avatars" + _install_avatars(candidate, candidate_avatars) + candidate_path = work_path / "roster.json" + candidate_path.write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + AVATAR_DIR.mkdir(parents=True, exist_ok=True) + for avatar in candidate_avatars.glob("*.webp"): + destination = AVATAR_DIR / avatar.name + if not destination.exists(): + shutil.copyfile(avatar, destination) + os.replace(candidate_path, ROSTER_PATH) + referenced = {pathlib.PurePosixPath(p["avatar"]).name for role in candidate["roles"].values() for p in role if p.get("avatar")} + for avatar in AVATAR_DIR.glob("*.webp"): + if avatar.name not in referenced: + avatar.unlink() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("refresh") + validate = commands.add_parser("validate") + validate.add_argument("--warn-after-days", type=int, default=90) + validate.add_argument("--skip-render", action="store_true") + args = parser.parse_args() + try: + if args.command == "refresh": + DATA_DIR.mkdir(parents=True, exist_ok=True) + refresh() + else: + if args.warn_after_days < 0: + raise RosterError("--warn-after-days must be non-negative") + for warning in validate_bundle(args.warn_after_days): + print(f"::warning file=data/community/roster.json::{warning}") + if not args.skip_render: + validate_rendered_outputs() + except (OSError, RosterError, urllib.error.URLError) as exc: + print(f"community roster: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py new file mode 100644 index 000000000..e01c441b8 --- /dev/null +++ b/scripts/test_community_roster.py @@ -0,0 +1,53 @@ +import importlib.util +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / "scripts" / "community_roster.py") +roster = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(roster) + + +class CommunityRosterTests(unittest.TestCase): + def fixture(self): + return ( + {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair Person"}}, "roster": {"chair": {}, "zeta": {}}}}}, + {"projects": {"hugegraph": {"owners": ["zeta", "chair"], "members": ["other", "zeta", "chair"]}}}, + {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": "Alpha Owner"}, "other": {"name": "Beta Committer"}}}, + {"schema_version": 1, "mappings": {}}, + ) + + def test_build_roster_derives_roles_and_order(self): + candidate = roster.build_roster(*self.fixture()) + self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in candidate["roles"]["pmc"]]) + self.assertEqual(["other"], [p["asf_id"] for p in candidate["roles"]["committers"]]) + self.assertTrue(candidate["roles"]["pmc"][0]["chair"]) + + def test_build_roster_rejects_committee_ldap_drift(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"].pop("zeta") + with self.assertRaisesRegex(roster.RosterError, "disagree"): + roster.build_roster(committee, projects, people, mapping) + + def test_mapping_requires_unique_numeric_ids(self): + mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", "user_id": 1}, "two": {"login": "other", "user_id": 1}}} + with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub user_id"): + roster._validate_mapping(mapping, {"one", "two"}) + + def test_checked_in_bundle_validates(self): + self.assertEqual([], roster.validate_bundle(90)) + + def test_fetch_failure_preserves_last_good(self): + original, old_fetch = roster.ROSTER_PATH.read_bytes(), roster._fetch_json + try: + roster._fetch_json = lambda _url: (_ for _ in ()).throw(OSError("network down")) + with self.assertRaises(OSError): + roster.refresh() + finally: + roster._fetch_json = old_fetch + self.assertEqual(original, roster.ROSTER_PATH.read_bytes()) + + +if __name__ == "__main__": + unittest.main()
