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: # noqa: D103 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 """Class representing an Insiders project.""" 36 37 name: str 38 url: str 39 40 41 @dataclass 42 class Feature: 43 """Class representing an Insiders feature.""" 44 45 name: str 46 ref: str | None 47 since: date | None 48 project: Project | None 49 50 def url(self, rel_base: str = "..") -> str | None: # noqa: D102 51 if not self.ref: 52 return None 53 if self.project: 54 rel_base = self.project.url 55 return posixpath.join(rel_base, self.ref.lstrip("/")) 56 57 def render(self, rel_base: str = "..", *, badge: bool = False) -> None: # noqa: D102 58 new = "" 59 if badge: 60 recent = self.since and date.today() - self.since <= timedelta(days=60) # noqa: DTZ011 61 if recent: 62 ft_date = self.since.strftime("%B %d, %Y") # type: ignore[union-attr] 63 new = f' :material-alert-decagram:{{ .new-feature .vibrate title="Added on {ft_date}" }}' 64 project = f"[{self.project.name}]({self.project.url}) — " if self.project else "" 65 feature = f"[{self.name}]({self.url(rel_base)})" if self.ref else self.name 66 print(f"- [{'x' if self.since else ' '}] {project}{feature}{new}") 67 68 69 @dataclass 70 class Goal: 71 """Class representing an Insiders goal.""" 72 73 name: str 74 amount: int 75 features: list[Feature] 76 complete: bool = False 77 78 @property 79 def human_readable_amount(self) -> str: # noqa: D102 80 return human_readable_amount(self.amount) 81 82 def render(self, rel_base: str = "..") -> None: # noqa: D102 83 print(f"#### $ {self.human_readable_amount} — {self.name}\n") 84 if self.features: 85 for feature in self.features: 86 feature.render(rel_base) 87 print("") 88 else: 89 print("There are no features in this goal for this project. ") 90 print( 91 "[See the features in this goal **for all Insiders projects.**]" 92 f"(https://pawamoy.github.io/insiders/#{self.amount}-{self.name.lower().replace(' ', '-')})", 93 ) 94 95 96 def load_goals(data: str, funding: int = 0, project: Project | None = None) -> dict[int, Goal]: 97 """Load goals from JSON data. 98 99 Parameters: 100 data: The JSON data. 101 funding: The current total funding, per month. 102 origin: The origin of the data (URL). 103 104 Returns: 105 A dictionaries of goals, keys being their target monthly amount. 106 """ 107 goals_data = yaml.safe_load(data)["goals"] 108 return { 109 amount: Goal( 110 name=goal_data["name"], 111 amount=amount, 112 complete=funding >= amount, 113 features=[ 114 Feature( 115 name=feature_data["name"], 116 ref=feature_data.get("ref"), 117 since=feature_data.get("since") and datetime.strptime(feature_data["since"], "%Y/%m/%d").date(), # noqa: DTZ007 118 project=project, 119 ) 120 for feature_data in goal_data["features"] 121 ], 122 ) 123 for amount, goal_data in goals_data.items() 124 } 125 126 127 def _load_goals_from_disk(path: str, funding: int = 0) -> dict[int, Goal]: 128 project_dir = os.getenv("MKDOCS_CONFIG_DIR", ".") 129 try: 130 data = Path(project_dir, path).read_text() 131 except OSError as error: 132 raise RuntimeError(f"Could not load data from disk: {path}") from error 133 return load_goals(data, funding) 134 135 136 def _load_goals_from_url(source_data: tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: 137 project_name, project_url, data_fragment = source_data 138 data_url = urljoin(project_url, data_fragment) 139 try: 140 with urlopen(data_url) as response: # noqa: S310 141 data = response.read() 142 except HTTPError as error: 143 raise RuntimeError(f"Could not load data from network: {data_url}") from error 144 return load_goals(data, funding, project=Project(name=project_name, url=project_url)) 145 146 147 def _load_goals(source: str | tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: 148 if isinstance(source, str): 149 return _load_goals_from_disk(source, funding) 150 return _load_goals_from_url(source, funding) 151 152 153 def funding_goals(source: str | list[str | tuple[str, str, str]], funding: int = 0) -> dict[int, Goal]: 154 """Load funding goals from a given data source. 155 156 Parameters: 157 source: The data source (local file path or URL). 158 funding: The current total funding, per month. 159 160 Returns: 161 A dictionaries of goals, keys being their target monthly amount. 162 """ 163 if isinstance(source, str): 164 return _load_goals_from_disk(source, funding) 165 goals = {} 166 for src in source: 167 source_goals = _load_goals(src, funding) 168 for amount, goal in source_goals.items(): 169 if amount not in goals: 170 goals[amount] = goal 171 else: 172 goals[amount].features.extend(goal.features) 173 return {amount: goals[amount] for amount in sorted(goals)} 174 175 176 def feature_list(goals: Iterable[Goal]) -> list[Feature]: 177 """Extract feature list from funding goals. 178 179 Parameters: 180 goals: A list of funding goals. 181 182 Returns: 183 A list of features. 184 """ 185 return list(chain.from_iterable(goal.features for goal in goals)) 186 187 188 def load_json(url: str) -> str | list | dict: # noqa: D103 189 with urlopen(url) as response: # noqa: S310 190 return json.loads(response.read().decode()) 191 192 193 data_source = globals()["data_source"] 194 sponsor_url = "https://github.com/sponsors/pawamoy" 195 data_url = "https://raw.githubusercontent.com/pawamoy/sponsors/main" 196 numbers: dict[str, int] = load_json(f"{data_url}/numbers.json") # type: ignore[assignment] 197 sponsors: list[dict] = load_json(f"{data_url}/sponsors.json") # type: ignore[assignment] 198 current_funding = numbers["total"] 199 sponsors_count = numbers["count"] 200 goals = funding_goals(data_source, funding=current_funding) 201 ongoing_goals = [goal for goal in goals.values() if not goal.complete] 202 unreleased_features = sorted( 203 (ft for ft in feature_list(ongoing_goals) if ft.since), 204 key=lambda ft: cast(date, ft.since), 205 reverse=True, 206 )