Skip to content

Commit 6d6e55e

Browse files
author
btraven00
committed
feat: show compat and commit version
we display 3 additional badges: - last commit in git repo, with link - last updated for the git repo (color-coded for freshness) - lower snakemake version that depends on the same version of the interface that the plugin depends on
1 parent 44b7911 commit 6d6e55e

7 files changed

Lines changed: 231 additions & 16 deletions

File tree

source/_static/custom.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
.image-reference img {
22
display: inline;
3+
margin-right: 4px;
34
}
45

56
.image-reference {
67
border-bottom: none !important;
8+
font-size: 0;
79
}
810

911
table code.literal {

source/_templates/executor_plugin.rst.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{% block type %}executor{% endblock %}
44

55
{% block usage %}
6-
In order to use the plugin, run Snakemake (>=8.0) in the folder where your workflow code and config resides (containing either ``workflow/Snakefile`` or ``Snakefile``) with the corresponding value for the executor flag::
6+
In order to use the plugin, run Snakemake ({{ snakemake_version if snakemake_version else ">=8.0" }}) in the folder where your workflow code and config resides (containing either ``workflow/Snakefile`` or ``Snakefile``) with the corresponding value for the executor flag::
77

88
snakemake --executor {{ plugin_name }} --default-resources --jobs N ...
99

source/_templates/logger_plugin.rst.j2

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
{% block type %}logger{% endblock %}
44

55
{% block usage %}
6-
In order to use the plugin, run Snakemake (>=9.0) with the corresponding value for the logger flag::
6+
In order to use the plugin, run Snakemake ({{ snakemake_version if snakemake_version else ">=9.0" }}) with the corresponding value for the logger flag::
77

88
snakemake --logger {{ plugin_name }} ...
99

1010
with ``...`` being any additional arguments you want to use.
11-
{% endblock %}
11+
{% endblock %}

source/_templates/plugin_base.rst.j2

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,36 @@
44
Snakemake {% block type %}{% endblock %} plugin: {{ plugin_name }}
55
###########################################
66

7-
{% if repository is not none %}
7+
{% if repository is not none -%}
88
.. image:: https://img.shields.io/badge/repository-{{ repository_type }}-blue?color=%23022c22
99
:target: {{ repository }}
10-
{% else %}
10+
{% else -%}
1111
.. image:: https://img.shields.io/badge/repository-unknown-blue?color=%23022c22
1212
:target: #
13-
{% endif %}
14-
15-
{% for author in authors %}
13+
{% endif -%}
14+
{% if commit_info is not none -%}
15+
.. image:: https://img.shields.io/badge/last_commit-{{ commit_info["sha"] }}-green?color={{ commit_age_color }}
16+
:alt: Last commit
17+
:target: {{ repository }}
18+
.. image:: https://img.shields.io/badge/last_updated-{{ commit_date_label }}-green?color={{ commit_age_color }}
19+
:alt: Last updated
20+
:target: {{ repository }}
21+
{% endif -%}
22+
{% for author in authors -%}
1623
.. image:: https://img.shields.io/badge/author-{{author|replace("-","--")|urlencode}}-purple?color=%23064e3b
1724
:target: https://pypi.org/project/{{ package_name }}
18-
{% endfor %}
19-
25+
{% endfor -%}
2026
.. image:: https://img.shields.io/pypi/v/{{ package_name }}?color=%23047857
2127
:alt: PyPI - Version
2228
:target: https://pypi.org/project/{{ package_name }}
23-
2429
.. image:: https://img.shields.io/pypi/l/{{ package_name }}?color=%2310b981
2530
:alt: PyPI - License
2631
:target: https://pypi.org/project/{{ package_name }}
32+
{% if snakemake_version is not none %}
33+
.. image:: https://img.shields.io/badge/snakemake-{{ snakemake_version|replace("-", "--") }}-blue?color=%230ea5e9
34+
:alt: Snakemake
35+
:target: https://snakemake.readthedocs.io
36+
{% endif %}
2737

2838
{% if repository is not none and not repository.startswith("https://github.com/snakemake/") %}
2939
.. warning::

source/_templates/report_plugin.rst.j2

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
{% block type %}report{% endblock %}
44

55
{% block usage %}
6-
In order to use the plugin, run Snakemake (>=8.5) with the corresponding value for the reporter flag::
6+
In order to use the plugin, run Snakemake ({{ snakemake_version if snakemake_version else ">=8.5" }}) with the corresponding value for the reporter flag::
77

88
snakemake --reporter {{ plugin_name }} ...
99

1010
with ``...`` being any additional arguments you want to use.
11-
{% endblock %}
11+
{% endblock %}

source/_templates/scheduler_plugin.rst.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{% block type %}scheduler{% endblock %}
44

55
{% block usage %}
6-
In order to use the plugin, run Snakemake (>=9.0) with the corresponding value for the scheduler flag::
6+
In order to use the plugin, run Snakemake ({{ snakemake_version if snakemake_version else ">=9.0" }}) with the corresponding value for the scheduler flag::
77

88
snakemake --scheduler {{ plugin_name }} ...
99

source/collect_plugins.py

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
from abc import ABC, abstractmethod
22
from collections import defaultdict
3+
from datetime import datetime, timezone
34
import json
45
import os
6+
import re
57
from pathlib import Path
68
import shutil
79
import subprocess
810
import sys
911
import tempfile
1012
import textwrap
1113
from typing import Any, Dict, List
14+
import urllib.parse
1215
import uuid
16+
from packaging.specifiers import SpecifierSet
17+
from packaging.version import Version
1318
from pypi_simple import PyPISimple
1419

1520
import requests
@@ -152,7 +157,7 @@ def plugin_type(self) -> str:
152157
def aux_info(self, metadata_collector) -> Dict[str, Any]:
153158
return {}
154159

155-
def collect_plugins(self, plugins, packages, templates):
160+
def collect_plugins(self, plugins, packages, templates, snakemake_compat_index):
156161
plugin_type = self.plugin_type()
157162
plugin_dir = Path("plugins") / plugin_type
158163
if plugin_dir.exists():
@@ -225,6 +230,7 @@ def get_setting_meta(setting, key, default="", verb=False):
225230
repository_type = "gitlab"
226231
docs_intro = get_docs(repository, section="intro")
227232
docs_further = get_docs(repository, section="further")
233+
228234
if repository is not None and docs_intro is None and docs_further is None:
229235
docs_warning = (
230236
f"No documentation found in repository {repository}. The plugin should "
@@ -233,6 +239,18 @@ def get_setting_meta(setting, key, default="", verb=False):
233239
"auto-generated usage instructions presented in this catalog."
234240
)
235241

242+
commit_info = get_commit(repository, repository_type)
243+
commit_date_label = (
244+
_commit_date_label(commit_info["date"]) if commit_info else None
245+
)
246+
commit_age_color = (
247+
_commit_age_color(commit_info["date"]) if commit_info else None
248+
)
249+
250+
snakemake_version = _plugin_min_snakemake(
251+
meta["info"].get("requires_dist"), snakemake_compat_index
252+
)
253+
236254
settings = {}
237255
aux_info = {}
238256

@@ -257,6 +275,10 @@ def get_setting_meta(setting, key, default="", verb=False):
257275
authors=authors,
258276
repository=repository,
259277
repository_type=repository_type,
278+
commit_info=commit_info,
279+
commit_age_color=commit_age_color,
280+
commit_date_label=commit_date_label,
281+
snakemake_version=snakemake_version,
260282
meta=meta,
261283
desc=desc,
262284
docs_intro=docs_intro,
@@ -310,6 +332,114 @@ def plugin_type(self):
310332
return "scheduler"
311333

312334

335+
def _commit_date_label(date_str: str) -> str:
336+
"""Return a shields.io-safe date label like 'March_2026' from an ISO date string."""
337+
return datetime.fromisoformat(date_str.replace("Z", "+00:00")).strftime("%B_%Y")
338+
339+
340+
def _commit_age_color(date_str: str) -> str:
341+
"""Return a shields.io color hex based on how old the commit date is."""
342+
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
343+
now = datetime.now(timezone.utc)
344+
months = (now.year - dt.year) * 12 + (now.month - dt.month)
345+
if months < 6:
346+
return "%2316a34a" # green
347+
elif months < 24:
348+
return "%23ea580c" # orange
349+
else:
350+
return "%23dc2626" # red
351+
352+
353+
_INTERFACE_PKG_RE = re.compile(
354+
r"(snakemake-interface-(?:executor|storage|report|logger|scheduler)-plugins)"
355+
r"\s*\(?([^)]+)"
356+
)
357+
358+
359+
def _build_snakemake_compat_index() -> list[tuple]:
360+
"""Fetch Snakemake release metadata from PyPI once and build a compatibility index.
361+
362+
Returns a sorted list of (snakemake_version, interface_pkg, lower, upper) tuples
363+
where lower/upper are Version objects (or None) from snakemake's interface requirement.
364+
"""
365+
print("Building Snakemake compatibility index...", file=sys.stderr)
366+
meta = pypi_api("https://pypi.org/pypi/snakemake/json")
367+
all_versions = sorted(
368+
[
369+
v
370+
for v in meta["releases"]
371+
if not Version(v).is_prerelease and Version(v) >= Version("8.0.0")
372+
],
373+
key=Version,
374+
)
375+
376+
# Only fetch first release per major.minor to minimise API calls
377+
seen_minor: set[tuple] = set()
378+
representative: list[str] = []
379+
for v in all_versions:
380+
p = Version(v)
381+
key = (p.major, p.minor)
382+
if key not in seen_minor:
383+
seen_minor.add(key)
384+
representative.append(v)
385+
386+
entries = []
387+
for snakemake_ver in representative:
388+
try:
389+
ver_meta = pypi_api(f"https://pypi.org/pypi/snakemake/{snakemake_ver}/json")
390+
except MetadataError:
391+
continue
392+
for dep in ver_meta["info"].get("requires_dist") or []:
393+
m = _INTERFACE_PKG_RE.search(dep)
394+
if not m:
395+
continue
396+
iface_pkg = m.group(1)
397+
spec = SpecifierSet(m.group(2).strip())
398+
lower = upper = None
399+
for s in spec:
400+
v = Version(s.version)
401+
if s.operator in (">=", ">"):
402+
if lower is None or v > lower:
403+
lower = v
404+
elif s.operator in ("<", "<="):
405+
if upper is None or v < upper:
406+
upper = v
407+
entries.append((Version(snakemake_ver), iface_pkg, lower, upper))
408+
409+
return sorted(entries, key=lambda e: e[0])
410+
411+
412+
def _plugin_min_snakemake(
413+
requires_dist: list[str] | None,
414+
compat_index: list[tuple],
415+
) -> str | None:
416+
"""Return the minimum Snakemake version (as '>=X.Y') compatible with a plugin."""
417+
if not requires_dist:
418+
return None
419+
420+
plugin_lower = None
421+
plugin_iface = None
422+
for dep in requires_dist:
423+
m = _INTERFACE_PKG_RE.search(dep)
424+
if m:
425+
plugin_iface = m.group(1)
426+
for s in SpecifierSet(m.group(2).strip()):
427+
if s.operator in (">=", ">"):
428+
v = Version(s.version)
429+
if plugin_lower is None or v > plugin_lower:
430+
plugin_lower = v
431+
break
432+
433+
if plugin_lower is None or plugin_iface is None:
434+
return None
435+
436+
for snakemake_ver, iface_pkg, _lower, upper in compat_index:
437+
if iface_pkg == plugin_iface and upper is not None and plugin_lower < upper:
438+
return f">={snakemake_ver.major}.{snakemake_ver.minor}"
439+
440+
return None
441+
442+
313443
def collect_plugins():
314444
templates = Environment(
315445
loader=FileSystemLoader("_templates"),
@@ -319,6 +449,7 @@ def collect_plugins():
319449
)
320450

321451
plugins = defaultdict(list)
452+
snakemake_compat_index = _build_snakemake_compat_index()
322453

323454
with PyPISimple() as pypi_client:
324455
packages = pypi_client.get_index_page().projects
@@ -330,7 +461,9 @@ def collect_plugins():
330461
LoggerPluginCollector,
331462
SchedulerPluginCollector,
332463
):
333-
collector().collect_plugins(plugins, packages, templates)
464+
collector().collect_plugins(
465+
plugins, packages, templates, snakemake_compat_index
466+
)
334467

335468
with open("index.rst", "w") as f:
336469
f.write(templates.get_template("index.rst.j2").render(plugins=plugins))
@@ -372,3 +505,73 @@ def retrieve():
372505
return m2r2.convert(retrieved, renderer=renderer)
373506
else:
374507
return None
508+
509+
510+
def _fetch_commit_info(repository: str, repository_type: str, branch: str):
511+
"""Fetch commit info for a specific branch using regex to parse URLs.
512+
513+
Args:
514+
repository: The repository URL
515+
repository_type: Either "github" or "gitlab"
516+
branch: The branch name to query
517+
518+
Returns:
519+
Dict with 'sha' and 'date' keys, or None if request fails
520+
"""
521+
if repository_type == "github":
522+
match = re.match(r"https://github\.com/([^/]+)/([^/]+?)/?$", repository)
523+
if not match:
524+
return None
525+
owner, repo = match.groups()
526+
527+
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch}?per_page=1&page=1"
528+
response = requests.get(api_url)
529+
if response.status_code == 200:
530+
data = response.json()
531+
return {
532+
"sha": data["sha"][:7],
533+
"date": data["commit"]["author"]["date"],
534+
}
535+
536+
elif repository_type == "gitlab":
537+
match = re.match(r"https://gitlab\.com/(.+?)/?$", repository)
538+
if not match:
539+
return None
540+
project_path = match.group(1)
541+
542+
encoded_path = urllib.parse.quote(project_path, safe="")
543+
api_url = f"https://gitlab.com/api/v4/projects/{encoded_path}/repository/commits/{branch}"
544+
response = requests.get(api_url)
545+
if response.status_code == 200:
546+
data = response.json()
547+
return {
548+
"sha": data["short_id"],
549+
"date": data["committed_date"],
550+
}
551+
552+
return None
553+
554+
555+
def get_commit(
556+
repository: str | None, repository_type: str | None, branches=["main", "master"]
557+
):
558+
"""Fetch the latest commit information from a GitHub or GitLab repository.
559+
560+
Returns a dict with commit info:
561+
- sha: short commit hash (7 chars)
562+
- date: commit date
563+
Returns None if repository is None or if the request fails.
564+
"""
565+
if repository is None or repository_type is None:
566+
return None
567+
568+
for branch in branches:
569+
try:
570+
result = _fetch_commit_info(repository, repository_type, branch)
571+
if result is not None:
572+
return result
573+
except Exception:
574+
# Continue to next branch on any error
575+
continue
576+
577+
return None

0 commit comments

Comments
 (0)