insiders.py
1 # Functions related to Insiders funding goals. 2 3 from __future__ import annotations 4 5 import json 6 import logging 7 import os 8 import posixpath 9 from dataclasses import dataclass 10 from datetime import date, datetime, timedelta 11 from itertools import chain 12 from pathlib import Path 13 from typing import TYPE_CHECKING, cast 14 from urllib.error import HTTPError 15 from urllib.parse import urljoin 16 from urllib.request import urlopen 17 18 import yaml 19 20 if TYPE_CHECKING: 21 from collections.abc import Iterable 22 23 logger = logging.getLogger(f"mkdocs.logs.{__name__}") 24 25 26 def human_readable_amount(amount: int) -> str: 27 str_amount = str(amount) 28 if len(str_amount) >= 4: # noqa: PLR2004 29 return f"{str_amount[: len(str_amount) - 3]},{str_amount[-3:]}" 30 return str_amount 31 32 33 @dataclass 34 class Project: 35 name: str 36 url: str 37 38 39 @dataclass 40 class Feature: 41 name: str 42 ref: str | None 43 since: date | None 44 project: Project | None 45 46 def url(self, rel_base: str = "..") -> str | None: # noqa: D102 47 if not self.ref: 48 return None 49 if self.project: 50 rel_base = self.project.url 51 return posixpath.join(rel_base, self.ref.lstrip("/")) 52 53 def render(self, rel_base: str = "..", *, badge: bool = False) -> None: # noqa: D102 54 new = "" 55 if badge: 56 recent = self.since and date.today() - self.since <= timedelta(days=60) # noqa: DTZ011 57 if recent: 58 ft_date = self.since.strftime("%B %d, %Y") # type: ignore[union-attr] 59 new = f' :material-alert-decagram:{{ .new-feature .vibrate title="Added on {ft_date}" }}' 60 project = f"[{self.project.name}]({self.project.url}) — " if self.project else "" 61 feature = f"[{self.name}]({self.url(rel_base)})" if self.ref else self.name 62 print(f"- [{'x' if self.since else ' '}] {project}{feature}{new}") 63 64 65 @dataclass 66 class Goal: 67 name: str 68 amount: int 69 features: list[Feature] 70 complete: bool = False 71 72 @property 73 def human_readable_amount(self) -> str: # noqa: D102 74 return human_readable_amount(self.amount) 75 76 def render(self, rel_base: str = "..") -> None: # noqa: D102 77 print(f"#### $ {self.human_readable_amount} — {self.name}\n") 78 if self.features: 79 for feature in self.features: 80 feature.render(rel_base) 81 print("") 82 else: 83 print("There are no features in this goal for this project. ") 84 print( 85 "[See the features in this goal **for all Insiders projects.**]" 86 f"(https://pawamoy.github.io/insiders/#{self.amount}-{self.name.lower().replace(' ', '-')})", 87 ) 88 89 90 def load_goals(data: str, funding: int = 0, project: Project | None = None) -> dict[int, Goal]: 91 goals_data = yaml.safe_load(data)["goals"] 92 return { 93 amount: Goal( 94 name=goal_data["name"], 95 amount=amount, 96 complete=funding >= amount, 97 features=[ 98 Feature( 99 name=feature_data["name"], 100 ref=feature_data.get("ref"), 101 since=feature_data.get("since") and datetime.strptime(feature_data["since"], "%Y/%m/%d").date(), # noqa: DTZ007 102 project=project, 103 ) 104 for feature_data in goal_data["features"] 105 ], 106 ) 107 for amount, goal_data in goals_data.items() 108 } 109 110 111 def _load_goals_from_disk(path: str, funding: int = 0) -> dict[int, Goal]: 112 project_dir = os.getenv("MKDOCS_CONFIG_DIR", ".") 113 try: 114 data = Path(project_dir, path).read_text() 115 except OSError as error: 116 raise RuntimeError(f"Could not load data from disk: {path}") from error 117 return load_goals(data, funding) 118 119 120 def _load_goals_from_url(source_data: tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: 121 project_name, project_url, data_fragment = source_data 122 data_url = urljoin(project_url, data_fragment) 123 try: 124 with urlopen(data_url) as response: # noqa: S310 125 data = response.read() 126 except HTTPError as error: 127 raise RuntimeError(f"Could not load data from network: {data_url}") from error 128 return load_goals(data, funding, project=Project(name=project_name, url=project_url)) 129 130 131 def _load_goals(source: str | tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: 132 if isinstance(source, str): 133 return _load_goals_from_disk(source, funding) 134 return _load_goals_from_url(source, funding) 135 136 137 def funding_goals(source: str | list[str | tuple[str, str, str]], funding: int = 0) -> dict[int, Goal]: 138 if isinstance(source, str): 139 return _load_goals_from_disk(source, funding) 140 goals = {} 141 for src in source: 142 source_goals = _load_goals(src, funding) 143 for amount, goal in source_goals.items(): 144 if amount not in goals: 145 goals[amount] = goal 146 else: 147 goals[amount].features.extend(goal.features) 148 return {amount: goals[amount] for amount in sorted(goals)} 149 150 151 def feature_list(goals: Iterable[Goal]) -> list[Feature]: 152 return list(chain.from_iterable(goal.features for goal in goals)) 153 154 155 def load_json(url: str) -> str | list | dict: 156 with urlopen(url) as response: # noqa: S310 157 return json.loads(response.read().decode()) 158 159 160 data_source = globals()["data_source"] 161 sponsor_url = "https://github.com/sponsors/pawamoy" 162 data_url = "https://raw.githubusercontent.com/pawamoy/sponsors/main" 163 numbers: dict[str, int] = load_json(f"{data_url}/numbers.json") # type: ignore[assignment] 164 sponsors: list[dict] = load_json(f"{data_url}/sponsors.json") # type: ignore[assignment] 165 current_funding = numbers["total"] 166 sponsors_count = numbers["count"] 167 goals = funding_goals(data_source, funding=current_funding) 168 ongoing_goals = [goal for goal in goals.values() if not goal.complete] 169 unreleased_features = sorted( 170 (ft for ft in feature_list(ongoing_goals) if ft.since), 171 key=lambda ft: cast(date, ft.since), 172 reverse=True, 173 )