/ scripts / insiders.py
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  )