/ 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:  # 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  )