2020-12-04 12:08:23 -05:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
2024-05-13 23:40:34 -04:00
|
|
|
# /// script
|
|
|
|
|
# dependencies = [
|
|
|
|
|
# "click",
|
|
|
|
|
# "pyyaml",
|
|
|
|
|
# "pygithub",
|
|
|
|
|
# ]
|
|
|
|
|
# ///
|
|
|
|
|
|
2020-12-04 16:21:09 -05:00
|
|
|
"""
|
|
|
|
|
Convert a yaml project list into a nice table.
|
|
|
|
|
|
|
|
|
|
Suggested usage:
|
|
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
./bin/projects.py docs/data/projects.yml --online --auth $GITHUB_API_TOKEN
|
2020-12-04 16:21:09 -05:00
|
|
|
git diff
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import builtins
|
|
|
|
|
import functools
|
2021-04-25 03:45:27 -04:00
|
|
|
import textwrap
|
2021-01-06 13:50:58 -05:00
|
|
|
import urllib.request
|
|
|
|
|
import xml.dom.minidom
|
2023-04-18 13:06:17 -04:00
|
|
|
from collections.abc import Iterable, Mapping, Sequence
|
2025-09-14 23:14:45 +05:30
|
|
|
from datetime import UTC, datetime
|
2020-12-04 16:21:09 -05:00
|
|
|
from io import StringIO
|
2021-01-06 13:50:58 -05:00
|
|
|
from pathlib import Path
|
2025-02-28 12:32:47 -05:00
|
|
|
from typing import Any, Self, TextIO
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
import click
|
|
|
|
|
import yaml
|
2021-01-09 15:41:41 -05:00
|
|
|
from github import Github, GithubException
|
2020-12-04 12:08:23 -05:00
|
|
|
|
2020-12-04 16:21:09 -05:00
|
|
|
ICONS = (
|
|
|
|
|
"github",
|
2020-12-04 16:44:04 -05:00
|
|
|
"azurepipelines",
|
2020-12-04 16:21:09 -05:00
|
|
|
"circleci",
|
|
|
|
|
"gitlab",
|
|
|
|
|
"travisci",
|
2022-07-24 01:25:27 +02:00
|
|
|
"cirrusci",
|
2020-12-04 16:21:09 -05:00
|
|
|
"windows",
|
|
|
|
|
"apple",
|
|
|
|
|
"linux",
|
|
|
|
|
)
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Project:
|
|
|
|
|
NAME: int = 0
|
|
|
|
|
|
2023-04-18 13:06:17 -04:00
|
|
|
def __init__(self, config: Mapping[str, Any], github: Github | None = None):
|
2020-12-04 15:46:22 -05:00
|
|
|
try:
|
|
|
|
|
self.name: str = config["name"]
|
|
|
|
|
self.gh: str = config["gh"]
|
|
|
|
|
except KeyError:
|
|
|
|
|
print("Invalid config, needs at least gh and name!", config)
|
|
|
|
|
raise
|
|
|
|
|
|
2020-12-04 12:08:23 -05:00
|
|
|
self.stars_repo: str = config.get("stars", self.gh)
|
|
|
|
|
self.notes: str = config.get("notes", "")
|
2021-04-25 03:45:27 -04:00
|
|
|
self.ci: list[str] = config.get("ci", [])
|
|
|
|
|
self.os: list[str] = config.get("os", [])
|
2020-12-04 12:08:23 -05:00
|
|
|
|
2020-12-04 16:21:09 -05:00
|
|
|
self.online = github is not None
|
|
|
|
|
if github is not None:
|
2021-01-09 15:41:41 -05:00
|
|
|
try:
|
|
|
|
|
repo = github.get_repo(self.stars_repo)
|
|
|
|
|
except GithubException:
|
|
|
|
|
print(f"Broken: {self.stars_repo}")
|
|
|
|
|
raise
|
|
|
|
|
|
2021-06-12 14:02:06 -04:00
|
|
|
self.num_stars: int = repo.stargazers_count
|
2020-12-04 16:21:09 -05:00
|
|
|
self.pushed_at = repo.pushed_at
|
|
|
|
|
if not self.notes:
|
|
|
|
|
notes = repo.description
|
2020-12-12 12:19:04 +00:00
|
|
|
if repo.description:
|
|
|
|
|
self.notes = notes
|
2020-12-04 12:58:27 -05:00
|
|
|
else:
|
|
|
|
|
self.num_stars = 0
|
2025-09-14 23:14:45 +05:30
|
|
|
self.pushed_at = datetime.now(UTC)
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
name_len = len(self.name) + 4
|
|
|
|
|
self.__class__.NAME = max(self.__class__.NAME, name_len)
|
|
|
|
|
|
2025-02-28 12:32:47 -05:00
|
|
|
def __lt__(self, other: Self) -> bool:
|
2020-12-04 16:21:09 -05:00
|
|
|
if self.online:
|
2020-12-04 12:58:27 -05:00
|
|
|
return self.num_stars < other.num_stars
|
|
|
|
|
else:
|
|
|
|
|
return self.name < other.name
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
@classmethod
|
2020-12-04 16:21:09 -05:00
|
|
|
def header(cls) -> str:
|
2021-04-30 17:56:34 -04:00
|
|
|
return textwrap.dedent(
|
|
|
|
|
f"""\
|
2025-01-20 11:17:01 +00:00
|
|
|
| {"Name":{cls.NAME}} | CI | OS | Notes |
|
|
|
|
|
|{"":-^{cls.NAME + 2}}|----|----|:------|"""
|
2021-04-30 17:56:34 -04:00
|
|
|
)
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def namelink(self) -> str:
|
|
|
|
|
return f"[{self.name}][]"
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def starslink(self) -> str:
|
2020-12-04 12:58:27 -05:00
|
|
|
return f"![{self.name} stars][]"
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def url(self) -> str:
|
|
|
|
|
return f"https://github.com/{self.gh}"
|
|
|
|
|
|
2020-12-04 12:58:27 -05:00
|
|
|
@property
|
|
|
|
|
def ci_icons(self) -> str:
|
|
|
|
|
return " ".join(f"![{icon} icon][]" for icon in self.ci)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def os_icons(self) -> str:
|
|
|
|
|
return " ".join(f"![{icon} icon][]" for icon in self.os)
|
|
|
|
|
|
2020-12-04 12:08:23 -05:00
|
|
|
def table_row(self) -> str:
|
2021-05-03 11:45:43 -04:00
|
|
|
notes = self.notes.replace("\n", " ")
|
2020-12-12 12:18:11 +00:00
|
|
|
return f"| {self.namelink: <{self.NAME}} | {self.ci_icons} | {self.os_icons} | {notes} |"
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
def links(self) -> str:
|
2020-12-07 09:31:08 -05:00
|
|
|
return f"[{self.name}]: {self.url}"
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
def info(self) -> str:
|
2025-09-14 23:14:45 +05:30
|
|
|
days = (datetime.now(UTC) - self.pushed_at).days
|
2020-12-04 12:58:27 -05:00
|
|
|
return f"<!-- {self.name}: {self.num_stars}, last pushed {days} days ago -->"
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
|
2020-12-12 14:47:14 +00:00
|
|
|
def fetch_icon(icon_name: str) -> None:
|
2021-05-03 11:45:43 -04:00
|
|
|
url = f"https://cdn.jsdelivr.net/npm/simple-icons@v4/icons/{icon_name}.svg"
|
2020-12-12 14:47:14 +00:00
|
|
|
with urllib.request.urlopen(url) as f:
|
|
|
|
|
original_svg_data = f.read()
|
|
|
|
|
|
|
|
|
|
document = xml.dom.minidom.parseString(original_svg_data)
|
|
|
|
|
svgElement = document.documentElement
|
2025-06-02 23:36:28 -04:00
|
|
|
assert svgElement is not None
|
2021-05-03 11:45:43 -04:00
|
|
|
assert svgElement.nodeName == "svg"
|
|
|
|
|
svgElement.setAttribute("width", "16px")
|
|
|
|
|
svgElement.setAttribute("fill", "#606060")
|
2020-12-12 14:47:14 +00:00
|
|
|
|
|
|
|
|
icon_path = path_for_icon(icon_name)
|
|
|
|
|
icon_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2021-05-03 11:45:43 -04:00
|
|
|
with open(path_for_icon(icon_name), "w") as f:
|
2020-12-12 14:47:14 +00:00
|
|
|
f.write(svgElement.toxml())
|
|
|
|
|
|
|
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
def path_for_icon(icon_name: str, relative_to: Path | None = None) -> Path:
|
2021-08-23 18:48:06 +01:00
|
|
|
from_path = Path.cwd() if relative_to is None else relative_to.parent
|
2021-07-20 18:50:45 +01:00
|
|
|
|
|
|
|
|
absolute_path = PROJECT_ROOT / "docs" / "data" / "readme_icons" / f"{icon_name}.svg"
|
|
|
|
|
|
|
|
|
|
return absolute_path.resolve().relative_to(from_path.resolve())
|
2020-12-12 14:47:14 +00:00
|
|
|
|
|
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
def get_projects(
|
2023-04-18 13:06:17 -04:00
|
|
|
config: Iterable[Mapping[str, Any]],
|
2021-04-30 17:56:34 -04:00
|
|
|
*,
|
|
|
|
|
online: bool = True,
|
|
|
|
|
auth: str | None = None,
|
2021-07-20 18:50:45 +01:00
|
|
|
) -> list[Project]:
|
2020-12-12 14:47:14 +00:00
|
|
|
if online:
|
|
|
|
|
for icon in ICONS:
|
|
|
|
|
fetch_icon(icon)
|
|
|
|
|
|
2020-12-04 16:21:09 -05:00
|
|
|
github = Github(auth) if online else None
|
|
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
return sorted((Project(item, github) for item in config), reverse=online)
|
|
|
|
|
|
|
|
|
|
|
2024-10-22 10:16:59 -04:00
|
|
|
def render_projects(
|
|
|
|
|
projects: Sequence[Project], *, dest_path: Path, include_info: bool = True
|
|
|
|
|
) -> str:
|
2021-07-20 18:50:45 +01:00
|
|
|
io = StringIO()
|
|
|
|
|
print = functools.partial(builtins.print, file=io)
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
print(Project.header())
|
2021-07-20 18:50:45 +01:00
|
|
|
|
2020-12-04 12:08:23 -05:00
|
|
|
for project in projects:
|
|
|
|
|
print(project.table_row())
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
for project in projects:
|
|
|
|
|
print(project.links())
|
|
|
|
|
|
2020-12-04 12:58:27 -05:00
|
|
|
print()
|
2020-12-04 16:21:09 -05:00
|
|
|
for icon in ICONS:
|
2021-07-20 18:50:45 +01:00
|
|
|
print(f"[{icon} icon]: {path_for_icon(icon, relative_to=dest_path).as_posix()}")
|
2020-12-04 12:58:27 -05:00
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
if include_info:
|
|
|
|
|
print()
|
|
|
|
|
for project in projects:
|
|
|
|
|
print(project.info())
|
2020-12-04 16:21:09 -05:00
|
|
|
|
|
|
|
|
return io.getvalue()
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
def insert_projects_table(
|
|
|
|
|
file: Path,
|
|
|
|
|
*,
|
2023-04-18 13:06:17 -04:00
|
|
|
projects: Sequence[Project],
|
2021-07-20 18:50:45 +01:00
|
|
|
input_filename: str,
|
|
|
|
|
include_info: bool = True,
|
2024-10-22 10:16:59 -04:00
|
|
|
) -> None:
|
2021-07-20 18:50:45 +01:00
|
|
|
text = file.read_text()
|
|
|
|
|
projects_table = render_projects(projects, include_info=include_info, dest_path=file)
|
|
|
|
|
|
|
|
|
|
start_str = "<!-- START bin/projects.py -->\n"
|
|
|
|
|
|
|
|
|
|
start = text.find(start_str)
|
|
|
|
|
assert start != -1
|
|
|
|
|
|
|
|
|
|
end = text.find("<!-- END bin/projects.py -->\n")
|
|
|
|
|
assert end != -1
|
|
|
|
|
|
|
|
|
|
generated_note = f"<!-- this section is generated by bin/projects.py. Don't edit it directly, instead, edit {input_filename} -->"
|
|
|
|
|
new_text = (
|
2025-01-20 11:17:01 +00:00
|
|
|
f"{text[: start + len(start_str)]}\n{generated_note}\n\n{projects_table}\n{text[end:]}"
|
2021-07-20 18:50:45 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
file.write_text(new_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parent / ".."
|
|
|
|
|
README_FILE = PROJECT_ROOT / "README.md"
|
|
|
|
|
DOCS_PAGE = PROJECT_ROOT / "docs" / "working-examples.md"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@click.command(help="Try ./bin/projects.py docs/data/projects.yml")
|
2020-12-04 12:08:23 -05:00
|
|
|
@click.argument("input", type=click.File("r"))
|
2020-12-04 16:21:09 -05:00
|
|
|
@click.option("--online/--no-online", default=True, help="Get info from GitHub")
|
|
|
|
|
@click.option("--auth", help="GitHub authentication token")
|
2021-07-20 18:50:45 +01:00
|
|
|
@click.option(
|
|
|
|
|
"--dry-run", default=False, help="Print the output, rather than writing it to files in the repo"
|
|
|
|
|
)
|
2020-12-04 16:21:09 -05:00
|
|
|
def projects(
|
2021-04-30 17:56:34 -04:00
|
|
|
input: TextIO,
|
|
|
|
|
online: bool,
|
|
|
|
|
auth: str | None,
|
2021-07-20 18:50:45 +01:00
|
|
|
dry_run: bool,
|
2020-12-04 16:21:09 -05:00
|
|
|
) -> None:
|
2020-12-04 12:08:23 -05:00
|
|
|
config = yaml.safe_load(input)
|
2021-07-20 18:50:45 +01:00
|
|
|
projects = get_projects(config, online=online, auth=auth)
|
2020-12-04 16:21:09 -05:00
|
|
|
|
2021-07-20 18:50:45 +01:00
|
|
|
if dry_run:
|
|
|
|
|
output = render_projects(projects, dest_path=README_FILE)
|
2020-12-04 16:21:09 -05:00
|
|
|
print(output)
|
|
|
|
|
else:
|
2021-07-20 18:50:45 +01:00
|
|
|
insert_projects_table(
|
|
|
|
|
README_FILE, projects=projects[:10], input_filename=input.name, include_info=False
|
|
|
|
|
)
|
|
|
|
|
insert_projects_table(
|
2022-10-30 00:09:33 +02:00
|
|
|
DOCS_PAGE, projects=projects, input_filename=input.name, include_info=False
|
2021-07-20 18:50:45 +01:00
|
|
|
)
|
2020-12-04 12:08:23 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
projects()
|